Make a day19 folder for today’s files, open it in your editor, and run each script from its terminal, as on earlier days. Later in the day you will install two tools into a virtual environment, so create one in day19 now if you like (Day 13: python -m venv .venv, then activate it).
A program is read far more often than it is written: by teammates, by reviewers, and by you a month from now. Yesterday’s tools were about what code can do. Today is about making code easy to read and hard to misuse. You will write down what type each value should be, so a reader knows what to pass in and a tool can catch mistakes before the program runs. You will learn the shared style rules that make every Python file look familiar, and let tools apply them for you. And you will write the docstrings and comments that answer a reader’s questions before they ask them.
19.1 Annotating Parameters and Return Values
Here is a function you could have written on Day 9. What should you pass to it?
def total_cost(price, quantity):
return price * quantity
print(total_cost(2.5, 4))
print(total_cost("2.5", 4))10.0
2.52.52.52.5
The name suggests numbers, but nothing says so. The second call passes the string "2.5", perhaps straight from input() (Day 3), and Python does not complain: * with a string and an int repeats the string (Day 3), so the “total cost” is the text 2.52.52.52.5. A docstring (Day 9) could say “price is a number”, but a sentence in English is easy to skip and impossible for a tool to check.
Day 17 showed a better way to say it. In a data class, x: int noted that the field x is meant to hold an int. That note was a type annotation. You can write the same kind of note on a function’s parameters and on its result. When an annotation states a type, it is called a type hint: a hint to the reader (and to tools) about what kind of value belongs there.
The syntax has two parts:
- A parameter hint goes after the parameter’s name: a colon, a space, then the type, as in
price: float. - A return hint goes after the closing parenthesis of the parameter list: a space, an arrow
->(a minus sign and a greater-than sign), a space, then the type of the value the function returns, and then the usual colon that ends thedefline.
Worked example: A receipt with type hints
def line_total(price: float, quantity: int = 1) -> float:
"""Return the cost of quantity items at price each."""
return price * quantity
def print_line(item: str, price: float, quantity: int = 1) -> None:
"""Print one receipt line, such as '4 x pens: 10.00'."""
print(f"{quantity} x {item}: {line_total(price, quantity):.2f}")
print_line("pens", 2.5, 4)
print_line("ruler", 1.75)
help(line_total)4 x pens: 10.00
1 x ruler: 1.75
Help on function line_total in module __main__:
line_total(price: float, quantity: int = 1) -> float
Return the cost of quantity items at price each.Read line 1 piece by piece:
| Piece | Meaning |
|---|---|
price: float |
price should be a float |
quantity: int |
quantity should be an int |
= 1 |
A default value (Day 10). With a hint, it goes after the type |
-> float |
The function returns a float |
: at the end |
The usual colon that starts the body |
- Line 6:
print_lineprints its line but returns nothing, so its return hint is-> None. A function that never runsreturnwith a value gives backNone(Day 9), and-> Nonesays so on purpose. Without it, a reader cannot tell “returns nothing” from “the author forgot to add a hint”. - Line 8 calls
line_total, which returns a float, and formats it with Day 3’s:.2f. - Line 13:
help()(Day 9) now shows the hints in the function’s signature, so anyone looking upline_totalsees what to pass without reading its body.
Notice the spaces in quantity: int = 1. On Day 10 you wrote defaults with no spaces, quantity=1. Once there is a hint, Python’s style guide puts a space on each side of the =, so the hint and the default are easy to tell apart. There is no space before the colon and one after it, and one space on each side of ->.
Now the most important fact about hints. Change the function to have hints, and call it with the wrong types again:
def line_total(price: float, quantity: int = 1) -> float:
"""Return the cost of quantity items at price each."""
return price * quantity
print(line_total(2.5, 4))
print(line_total("2.5", 4))
print(line_total([0], 3))10.0
2.52.52.52.5
[0, 0, 0]
The hints did not stop anything. Python does not check type hints when it runs your program. They are stored with the function (that is how help() found them), and otherwise ignored, exactly like the data class fields on Day 17 that accepted Point("two", "three"). Python works this way on purpose: hints were added to the language long after it was created, and checking every argument on every call would slow every program down. So who reads the hints? People, your editor, which can suggest and warn as you type, and a type checker, a separate tool that you will run in 19.4.
Key idea
A type hint states what type a value should have: name: type after a parameter, and -> type before the colon of
the def line for the return value, with -> None for a function that returns nothing. Python stores hints but does
not check them when the program runs.
Watch out
Writing = where you mean :. price=float does not say “price is a float”. It makes the class float itself the default value of price. The function still runs when you pass every argument, which hides the mistake until a call leaves one out:
def line_total(price=float, quantity=int):
"""Return the cost of quantity items at price each."""
return price * quantity
print(line_total(2.5, 4))
print(line_total(2.5))10.0
Traceback (most recent call last):
File ".../day19/equals_not_colon.py", line 7, in <module>
print(line_total(2.5))
~~~~~~~~~~^^^^^
File ".../day19/equals_not_colon.py", line 3, in line_total
return price * quantity
~~~~~~^~~~~~~~~~
TypeError: unsupported operand type(s) for *: 'float' and 'type'On line 7, quantity takes its default, which is the type int itself (shown as 'type' in the message), and a number cannot be multiplied by a type. A hint uses a colon: price: float, quantity: int. If you also want a default, add it after the hint: quantity: int = 1.
Pause and try
Question 1 · Multiple choice
A function make_tag takes a person’s name and their age in whole years, and returns a name tag as a string. Which def line has the right hints?
def make_tag(name: str, age: int) -> str:def make_tag(name=str, age=int) -> str:def make_tag(name: str, age: int): -> strdef make_tag(str: name, int: age) -> str:
Check your reasoning
A. Each parameter is followed by a colon and its type, and the return type follows -> before the final colon.
- B is wrong:
=sets default values, sonamewould default to the classstritself. Nothing says what type the arguments should be. - C is wrong: the return hint must come before the colon that ends the
defline. Placed after it,-> stris a syntax error. - D is wrong: the order is backwards. The parameter’s name comes first, then the colon, then the type.
Reread 19.1 Annotating Parameters and Return Values if you chose differently.
Question 2 · Tracing
What does this program print? Think about what Python does with hints when it runs.
def repeat(word: str, times: int) -> str:
return word * times
def half(number: int) -> int:
return number / 2
print(repeat("ha", 3))
print(repeat(3, 2))
print(half(7))Check your reasoning
hahaha
6
3.5Python ignores the hints at run time, so each line does whatever the values themselves do. Line 9 repeats "ha" 3 times. Line 10 passes the int 3 as word, so 3 * 2 is ordinary multiplication: 6, even though the hints say word is a string and the result is a string. Line 11: / always gives a float (Day 2), so half(7) returns 3.5, even though the return hint says int. If you expected an error, remember that only a type checker would report these mistakes (mypy points to line 10, and to line 6, where half returns a float); running the program does not.
Reread 19.1 Annotating Parameters and Return Values if your output differed.
19.2 Hints for Lists, Dictionaries, and Tuples
A hint of list tells a reader that a parameter is a list, but a list of what? Scores, names, tasks? A function that averages scores needs numbers in its list, and a dictionary could map anything to anything. Hints can say what goes inside a collection too, by putting the inner types in square brackets after the collection’s type:
| Hint | Means | A value that fits |
|---|---|---|
list[int] |
A list in which every item is an int | [85, 92, 78] |
set[str] |
A set in which every item is a string | {"red", "blue"} |
dict[str, int] |
A dictionary with string keys and int values | {"Ana": 3, "Ben": 5} |
tuple[str, int] |
A tuple of exactly two items: first a string, then an int | ("Ana", 92) |
list[list[int]] |
A list whose items are lists of ints (a grid, Day 6) | [[1, 2], [3, 4]] |
dict[str, list[int]] |
A dictionary from strings to lists of ints | {"Ana": [90, 85], "Ben": [72]} |
A list or a set takes one inner type, because every item is meant to be the same kind of thing. A dictionary takes two: the key type, then the value type. A tuple is different: it lists a type for each position, because a tuple usually holds a fixed number of values with different meanings (Day 7), like a name and a score. Read a nested hint from the outside in: dict[str, list[int]] is “a dictionary whose keys are strings and whose values are lists of ints”.
Worked example: Counting words, with hints
def count_words(text: str) -> dict[str, int]:
"""Return how many times each word appears in text."""
counts: dict[str, int] = {}
for word in text.lower().split():
counts[word] = counts.get(word, 0) + 1
return counts
def most_common(counts: dict[str, int]) -> tuple[str, int]:
"""Return the most common word and its count."""
best_word = ""
best_count = 0
for word, count in counts.items():
if count > best_count:
best_word, best_count = word, count
return best_word, best_count
counts = count_words("The cat saw the dog and the dog saw the cat")
print(counts)
word, count = most_common(counts)
print(f"{word} appears {count} times"){'the': 4, 'cat': 2, 'saw': 2, 'dog': 2, 'and': 1}
the appears 4 times- Line 1:
count_wordstakes a string and returns a dictionary from each word (a string) to its count (an int), which isdict[str, int]. - Line 3 is new. A hint can go on an ordinary variable too, with the same
name: typeform as a data class field, followed by=and its first value. This is a variable annotation.{}on its own says nothing about what will go into the dictionary; the annotation records the plan, so a reader (and a type checker) knows that only string keys and int values belong incounts. Most variables do not need one, because their first value already shows the type:total = 0is clearly an int. Annotate a variable when it starts empty or could otherwise be unclear. - Line 9:
most_commonreturns two values. Day 9 showed thatreturn a, breally returns one tuple, so the return hint istuple[str, int]: a word, then its count. Line 21 unpacks it (Day 7).
Hints can nest as deep as the data does. Here a gradebook maps each student to a list of scores:
def averages(grades: dict[str, list[int]]) -> dict[str, float]:
"""Return each student's average grade, rounded to 1 decimal place."""
result: dict[str, float] = {}
for name, scores in grades.items():
result[name] = round(sum(scores) / len(scores), 1)
return result
def top_students(results: dict[str, float], limit: float) -> list[str]:
"""Return the names of students whose average is at least limit."""
return [name for name, average in results.items() if average >= limit]
grades = {"Ana": [90, 85, 88], "Ben": [72, 80], "Cy": [95, 91, 99]}
results = averages(grades)
print(results)
print(top_students(results, 85)){'Ana': 87.7, 'Ben': 76.0, 'Cy': 95.0}
['Ana', 'Cy']
Each function’s first line now tells you the shape of the data it expects and returns: averages turns dict[str, list[int]] (names to lists of scores) into dict[str, float] (names to one average each), and top_students returns list[str], a list of names. Ana’s average is (90 + 85 + 88) / 3 = 87.67, rounded to 87.7, and Cy’s is 95.0; both reach the limit of 85, Ben’s 76.0 does not.
Any class can be a type in a hint, including your own. If Task is the data class from Day 17, then tasks: list[Task] means “a list of Task objects”, and -> Task means the function returns one. You will hint the whole to-do manager this way in today’s practice.
You may also meet older code that writes List[int] and Dict[str, int], with capital letters, imported from the standard library’s typing module (from typing import List, Dict). Python versions before 3.9 needed those. They mean the same thing; write the lowercase built-in forms in new code.
Key idea
Put inner types in square brackets: list[int] and set[str] take one type for every item, dict[str, int] takes
the key type and then the value type, and tuple[str, int] gives one type per position. Hints nest, any class can be
a type, and name: type = value annotates a variable that starts empty.
Watch out
Listing one type per item for a list. A list of three ints is not list[int, int, int]: a list hint takes exactly one type, which applies to every item however many there are. Type checkers reject list[int, int, int] as having too many type arguments. If the positions really mean different things, such as a name and a score, the value should probably be a tuple, and then tuple[str, int] is right. The reverse mistake also happens: tuple[int] means a tuple of exactly one int, not “a tuple of ints”.
Pause and try
Question 3 · Multiple choice
A function returns a value like {"Ana": [90, 85], "Ben": [72]}. Which return hint describes it?
dict[str, int]dict[list[int], str]dict[str, list[int]]list[dict[str, int]]
Check your reasoning
C. The keys are strings (names) and each value is a list of ints, and a dictionary hint lists the key type first, then the value type.
- A is wrong: the values are lists, not single ints.
- B is wrong: it has the key and value types the wrong way round. (A list could not even be a key, because keys must be hashable, Day 8.)
- D is wrong: the value is a single dictionary, not a list of dictionaries.
Reread 19.2 Hints for Lists, Dictionaries, and Tuples if you chose differently.
Question 4 · Short answer
Write the def line, with hints, for each function: (a) cheapest(prices) takes a dictionary from item names to prices such as 2.25, and returns the name of the cheapest item; (b) split_name(full_name) takes "Ana Lee" and returns the pair ("Ana", "Lee"); (c) grid(rows, columns) takes two whole numbers and returns a list of rows, each a list of zeros.
Check your reasoning
Model answer:
def cheapest(prices: dict[str, float]) -> str:
def split_name(full_name: str) -> tuple[str, str]:
def grid(rows: int, columns: int) -> list[list[int]]:(These are only the first lines; each function still needs a body.) For (a), the keys are names (strings) and the values are prices (floats), and the function returns one name. For (b), a pair of strings is a tuple with two positions, so it needs one type for each. For (c), the result is a list whose items are lists of ints, which is a nested hint. A correct answer has the parameter hints with colons, the return hint after ->, and the inner types in square brackets.
Reread 19.2 Hints for Lists, Dictionaries, and Tuples if your reasoning differed.
19.3 Values That Might Be None
Many functions you have written return a value or None. Day 8’s get() returns None for a missing key, and the to-do manager’s complete_task (Day 17) returns the Task it changed, or None if the task number does not exist. What is the return hint for “a float, or None if the item is not found”? -> float would be a lie, and a caller who believed it would use the result as a number without checking.
Python writes “one type or another” with the | symbol, read as “or”. It is the same symbol that joined two sets on Day 7, here joining two types. A hint such as int | str (an int or a string) is called a union type, and the most common union by far is X | None: “a value of type X, or None”.
Worked example: A price that might be missing
def find_price(prices: dict[str, float], item: str) -> float | None:
"""Return the price of item, or None if the shop does not sell it."""
return prices.get(item)
prices = {"apple": 0.5, "bread": 2.25}
for item in ["bread", "milk"]:
price = find_price(prices, item)
if price is None:
print(f"Sorry, we do not sell {item}.")
else:
print(f"Three {item}: {price * 3:.2f}")Three bread: 6.75
Sorry, we do not sell milk.- Line 1: the return hint
float | Nonewarns every caller that the result might beNone. - Line 3:
get()returns the price, orNonewhen the key is missing (Day 8). The hint describes exactly that. - Line 9 is the check the hint asks for.
is None(Day 4) splits the two cases. Inside theelsebranch,pricecannot beNone, so line 12 can safely multiply it: 2.25 × 3 = 6.75.
The same idea works for parameters. A parameter whose default is None (Day 10 used this to avoid the shared-list trap) has the hint X | None, with the default after it. And a union does not have to include None:
def greeting(name: str | None = None) -> str:
"""Return a greeting for name, or for everyone if no name is given."""
if name is None:
return "Hello, everyone!"
return f"Hello, {name}!"
def seat_label(seat: int | str) -> str:
"""Return a label for a seat given as a number (12) or a code ("12B")."""
return f"Seat {seat}"
print(greeting())
print(greeting("Ana"))
print(seat_label(12), seat_label("12B"))Hello, everyone!
Hello, Ana!
Seat 12 Seat 12B
name: str | None = None says that name is a string, or None, and is None when left out; line 3 handles that case first. seat: int | str accepts a seat number like 12 or a code like "12B"; the f-string works for both. Unions of unrelated types are less common than X | None, because a function that accepts many types has to handle each one, but they are sometimes exactly right.
The | form needs Python 3.10 or newer. In older code you will see the same hints written with names from the typing module: Optional[float] means float | None, and Union[int, str] means int | str.
Key idea
X | None means “a value of type X, or None”. Use it whenever a function can return None or a parameter defaults
to None, and check with is None before using the value. | joins any types, as in int | str.
Watch out
Using a maybe-None result without checking. The hint warns you, but Python does not stop you:
def find_price(prices: dict[str, float], item: str) -> float | None:
"""Return the price of item, or None if the shop does not sell it."""
return prices.get(item)
prices = {"apple": 0.5, "bread": 2.25}
for item in ["bread", "milk"]:
price = find_price(prices, item)
print(f"Three {item}: {price * 3:.2f}")Three bread: 6.75
Traceback (most recent call last):
File ".../day19/none_crash.py", line 9, in <module>
print(f"Three {item}: {price * 3:.2f}")
~~~~~~^~~
TypeError: unsupported operand type(s) for *: 'NoneType' and 'int'Bread works, so the bug hides until someone asks for milk. NoneType in the message is the type of None (Day 2): price was None. Whenever a hint says | None, handle the None case before using the value, as find_price.py does. In 19.4 a type checker finds this line for you without running the program.
Pause and try
Question 5 · Tracing
What does this program print?
def first_long_word(words: list[str], length: int) -> str | None:
for word in words:
if len(word) >= length:
return word
return None
words = ["fig", "plum", "banana"]
for length in [4, 6, 7]:
found = first_long_word(words, length)
if found is None:
print(length, "none")
else:
print(length, found.upper())Check your reasoning
4 PLUM
6 BANANA
7 nonelength |
Words checked | Returns | Printed |
|---|---|---|---|
| 4 | fig (3, too short), plum (4, long enough) |
"plum" |
4 PLUM |
| 6 | fig, plum, banana (6) |
"banana" |
6 BANANA |
| 7 | all three are shorter than 7; the loop ends | None |
7 none |
The return hint str | None matches both endings: a word from line 4, or None from line 5. Line 11 checks for None before line 14 calls upper(), so the third case prints none instead of crashing.
Reread 19.3 Values That Might Be None if your output differed.
Question 6 · Multiple choice
The to-do manager’s delete_task(tasks, number) removes and returns the Task with that number, or returns None if there is no such task. Which return hint is right?
-> Task-> None-> Task | None-> list[Task]
Check your reasoning
C. The function returns one of two things, a Task or None, and Task | None says exactly that.
- A is wrong: it promises a
Taskevery time, so a caller would not know to check forNone. - B is wrong:
-> Nonemeans the function never returns a useful value, but it does return the deleted task. - D is wrong: the function returns one task, not the list.
Reread 19.3 Values That Might Be None if you chose differently.
19.4 Checking Types with mypy or pyright
Hints are only notes until something reads them. Here is a short program whose hints are right but whose code is wrong in two places. It runs without a single error:
def label(item: str, quantity: int) -> str:
"""Return a label such as '3 x pens'."""
return f"{quantity} x {item}"
def total_items(basket: dict[str, int]) -> int:
"""Return how many items are in the basket altogether."""
total = 0
for quantity in basket.values():
total += quantity
return f"{total} items"
basket = {"pens": 3, "pads": 2}
print(label(3, "pens"))
print(total_items(basket))pens x 3
5 items
Both lines are wrong. Line 15 passes the arguments in the wrong order, so the label is backwards. Line 11 returns a string, although the hint promises an int; the output looks fine today, but the first caller that does arithmetic with the result, such as total_items(basket) + 1, will crash. Running a program only tests the lines and values that happen to run. A bug in a rarely used branch can wait months for the day it runs.
A type checker is a program that reads your code without running it and reports every place where the values do not match the hints. Checking code by reading it, without running it, is called static analysis (“static” because the program stands still). The two most widely used type checkers for Python are mypy and pyright. Both are third-party tools: they are not part of Python, and you install them with pip (Day 13), ideally into the project’s virtual environment. You only need one; they find the same kinds of mistakes.
python -m pip install mypy
mypy checkout.py
or
python -m pip install pyright
pyright checkout.py
Type the second line of each pair in the terminal, in the folder holding the file. Use mypy . (or pyright .) to check every .py file in the current folder and the folders inside it; the dot means “this folder”. Your editor can show the same complaints as you type: VS Code’s Python extension includes Pylance, which is built on pyright, and underlines mismatches once its type checking setting is turned on.
Worked example: What mypy reports
Here is what mypy prints for checkout.py. This is sample output: the exact wording and layout vary between versions.
checkout.py:11: error: Incompatible return value type (got "str", expected "int") [return-value]
checkout.py:15: error: Argument 1 to "label" has incompatible type "int"; expected "str" [arg-type]
checkout.py:15: error: Argument 2 to "label" has incompatible type "str"; expected "int" [arg-type]
Found 3 errors in 1 file (checked 1 source file)Each line is one complaint. Read it left to right:
| Part | Meaning |
|---|---|
checkout.py:11: |
The file and the line number |
error: |
How serious it is |
Incompatible return value type |
What does not match |
(got "str", expected "int") |
The type it found, then the type the hint promised |
[return-value] |
The short name of the rule, useful for looking the error up |
So mypy found both bugs: line 11 returns a str where the hint says int, and line 15’s two arguments are swapped (one complaint for each). Fix the code, not the hints, because the hints describe what the functions are supposed to do:
def label(item: str, quantity: int) -> str:
"""Return a label such as '3 x pens'."""
return f"{quantity} x {item}"
def total_items(basket: dict[str, int]) -> int:
"""Return how many items are in the basket altogether."""
total = 0
for quantity in basket.values():
total += quantity
return total
basket = {"pens": 3, "pads": 2}
print(label("pens", 3))
print(f"{total_items(basket)} items")3 x pens
5 itemsRun the checker again, and it reports something like:
Success: no issues found in 1 source fileA few things to know about how type checkers behave:
- They can only check what is hinted. mypy, by default, does not check the inside of a function that has no hints at all, so an unhinted function is invisible to it. Hint every function’s parameters and return value; that is also today’s first practice exercise.
- An int is accepted where a float is expected. A whole number is a perfectly good price, so
line_total(2, 3)passes a check even though the hint saysprice: float. The reverse is not true: a float where an int is expected is reported. - A complaint is a question, not a verdict. Usually it is a real bug. Sometimes the code is right and the hint is wrong, for example
-> inton a function that really should return a string. Then fix the hint. Either way, the hint and the code end up agreeing. - Checking does not replace running. A type checker proves that the types fit, not that the answers are right:
return price + quantityinstead of*has perfectly good types. Day 20’s tests check the answers.
Key idea
A type checker such as mypy or pyright reads your code without running it and reports every place where a value does not match a hint, with the file, the line, the type it found, and the type it expected. It finds bugs in lines that have not run yet, but only in code that has hints.
Watch out
Using an X | None value without checking it. This program runs fine, because "Water plants" is in the list:
from dataclasses import dataclass
@dataclass
class Task:
title: str
done: bool = False
def find_task(tasks: list[Task], title: str) -> Task | None:
"""Return the task with this title, or None if there is none."""
for task in tasks:
if task.title == title:
return task
return None
tasks = [Task("Practice piano"), Task("Water plants")]
task = find_task(tasks, "Water plants")
print(task.done)FalseA type checker still complains about line 20 (sample mypy output; the wording varies by version):
find_task_unchecked.py:20: error: Item "None" of "Task | None" has no attribute "done" [union-attr]“Item None of Task | None” means: one of the two possible types of task is None, and None has no attribute done. The checker does not know which title you will search for, so it considers every value the hint allows, including the one that crashed none_crash.py in 19.3. Add the check, and the complaint goes away:
from dataclasses import dataclass
@dataclass
class Task:
title: str
done: bool = False
def find_task(tasks: list[Task], title: str) -> Task | None:
"""Return the task with this title, or None if there is none."""
for task in tasks:
if task.title == title:
return task
return None
tasks = [Task("Practice piano"), Task("Water plants")]
for title in ["Water plants", "Swim"]:
task = find_task(tasks, title)
if task is None:
print(f"No task called {title}")
else:
print(task.done)False
No task called SwimAfter if task is None:, the checker knows that task in the else branch is a Task, so task.done is safe.
Pause and try
Question 7 · Multiple choice
def area(width: float, height: float) -> float:
return width * height
def describe(name: str, size: float) -> str:
return f"{name}: {size} square meters"
print(describe("kitchen", area(3, 4)))
print(describe(area(2.5, 4), "hall"))Which line does a type checker report?
- The first
printline, because 3 and 4 are ints, not floats - The second
printline, because the arguments todescribeare in the wrong order - The
return f"..."line, because an f-string is not a float - No line, because the program runs without an error
Check your reasoning
B. describe expects a string, then a float. The second print passes a float (the result of area) as name and a string as size, so both arguments are reported.
- A is wrong: an int is accepted where a float is expected, because a whole number is a perfectly good width.
- C is wrong: that
returnis indescribe, whose hint is-> str, and an f-string is a string. - D is wrong: a type checker does not run the program. Python runs this program without an error, but its second line of output is
10.0: hall square meters, which is nonsense, and that is exactly the kind of bug a checker catches.
Reread 19.4 Checking Types with mypy or pyright if you chose differently.
Question 8 · Short answer
A classmate says: “My program ran and printed the right answer, so running a type checker on it is a waste of time.” Give two reasons they are wrong.
Check your reasoning
Model answer:
- Running the program only tests the lines and values that ran this time. A type checker reads every line, including branches that rarely run (such as the “not found” case), so it finds bugs that are waiting for an unusual input.
- A program can print the right thing today with a wrong type, as checkout.py’s
total_itemsdid, and then crash later when other code relies on the hint (for example, adding 1 to a result that is really a string).
Other good reasons: the checker finds mistakes in seconds, before anyone runs the program, and it keeps the hints honest, so readers can trust them. A correct answer mentions that the checker examines code that did not run, or catches wrong types that have not caused a visible problem yet.
Reread 19.4 Checking Types with mypy or pyright if your reasoning differed.
19.5 PEP 8: Naming and Whitespace
Python accepts both of these programs, and both print the same line. Which would you rather read?
MaxScore=100
def Percent(Score,outOf = MaxScore):
return round(Score/outOf*100 ,1)
class student_record:
def __init__( self,name,score ):
self.name=name
self.score=score
def summary(self):
return f"{self.name}: {Percent(self.score)}%"
print(student_record ('Ana',87).summary())MAX_SCORE = 100
def percent(score, out_of=MAX_SCORE):
return round(score / out_of * 100, 1)
class StudentRecord:
def __init__(self, name, score):
self.name = name
self.score = score
def summary(self):
return f"{self.name}: {percent(self.score)}%"
print(StudentRecord("Ana", 87).summary())Ana: 87.0%
The first one makes you work. Is Percent a class or a function? Is student_record a variable? Where does one definition end and the next begin? None of this matters to Python, but all of it matters to the people who read the code. A program is read many more times than it is written, so the Python community agreed on one shared set of style rules. Once everyone follows them, any Python file looks familiar and the reader can concentrate on what the code does.
Changes to Python are proposed in numbered documents called PEPs (Python Enhancement Proposals). PEP 8 is the official style guide for Python code; you met a few of its rules on Days 9 and 10. Its main naming and whitespace rules are below. Each one has the same reason behind it: code is easier to read when the shape of a line tells you what it is.
Naming
| Kind of name | Style | Examples |
|---|---|---|
| Variable, function | snake_case |
total_cost, percent, add_task |
| Method, attribute | snake_case |
summary, self.score |
| Class, exception | CapWords |
StudentRecord, TaskNumberError |
| Constant (Day 13) | UPPER_CASE with _ |
MAX_SCORE, SAVE_FILE |
| Module (file) | short, all lowercase | tasks.py, reading_log.py |
Because the styles differ, a name tells you what it is at a glance: StudentRecord(...) makes an object, percent(...) calls a function, and MAX_SCORE should never be reassigned. Two more rules: never use l (lowercase L), O (capital o), or I (capital i) as a one-letter name, because in many fonts they look like the digits 1 and 0; and choose names that say what a value means (score, not s).
Whitespace
| Rule | Write | Not |
|---|---|---|
One space each side of =, +=, comparisons, and and/or |
total = price + tax |
total=price+tax |
| A space after a comma, none before | percent(score, 100) |
percent(score ,100) |
| No space just inside brackets | summary(self), scores[0] |
summary( self ) |
No space before the ( of a call or the [ of an index |
print(x), scores[0] |
print (x), scores [0] |
No spaces around = for a default or keyword argument with no hint |
out_of=MAX_SCORE |
out_of = MAX_SCORE |
Spaces around = when the parameter has a hint (19.1) |
rate: float = 0.08 |
rate: float=0.08 |
| Indent with 4 spaces | (as in every example) | tabs, or 2 spaces |
| Two blank lines around top-level functions and classes, one between methods | (see tidy_grades.py) | none |
Worked example: Tidying the grades program
Compare the two files line by line:
| Messy | Tidy | Rule |
|---|---|---|
MaxScore=100 |
MAX_SCORE = 100 |
Constants in capitals; spaces around = |
def Percent(Score,outOf = ...) |
def percent(score, out_of=...) |
Function and parameters in snake_case; space after commas; no spaces around a default’s = |
Score/outOf*100 ,1 |
score / out_of * 100, 1 |
Spaces around operators; none before a comma |
class student_record: |
class StudentRecord: |
Classes in CapWords |
__init__( self,name,score ) |
__init__(self, name, score) |
No spaces inside brackets |
| no blank lines | two between top-level definitions, one between methods | Blank lines show where each definition ends |
student_record ('Ana',87) |
StudentRecord("Ana", 87) |
No space before (; double quotes, as everywhere in this course |
PEP 8 itself allows single or double quotes, as long as a file is consistent; most projects, and the formatters in 19.7, choose double quotes. Nothing in the tidy version changes what the program does. Python ran both identically; only the reader’s experience changed.
PEP 8 also warns against following it blindly. Its first section is titled “A Foolish Consistency is the Hobgoblin of Little Minds”: consistency within one project matters more than any single rule, and a rule can be broken when following it would make a particular line harder to read. When you join an existing project, follow the style it already uses.
Key idea
PEP 8 is Python’s official style guide. Name variables and functions in snake_case, classes in CapWords, and
constants in UPPER_CASE; put single spaces around = and operators and after commas, none inside brackets; indent 4
spaces; and separate top-level definitions with two blank lines.
Watch out
Naming a variable after a built-in. list, sum, max, str, and input are not keywords (Day 2), so Python lets you use them as variable names. The new name then hides the built-in for the rest of the file:
list = ["milk", "eggs", "bread"]
print(len(list), "items")
letters = list("abc")
print(letters)3 items
Traceback (most recent call last):
File ".../day19/shadow_list.py", line 3, in <module>
letters = list("abc")
TypeError: 'list' object is not callableAfter line 1, list means the shopping list, not the built-in function, and a list cannot be called. This is the same name-hiding that Day 13 showed for a file called random.py. Use a name that says what the list holds, such as shopping. If a built-in’s name really is the best word, programmers add an underscore at the end, list_, the habit PEP 8 recommends for names that clash with keywords, such as class_.
Pause and try
Question 9 · Multiple choice
Which line follows PEP 8’s naming rules?
class bank_account:MAX_RETRIES = 3def CalculateTotal(prices):TaxRate = 0.08for a variable that changes as the program runs
Check your reasoning
B. A constant, a value meant never to change, is written in capitals with underscores.
- A is wrong: class names use CapWords, so it should be
class BankAccount:. - C is wrong: function names use snake_case, so it should be
def calculate_total(prices):. A CapWords name makes a reader thinkCalculateTotal(...)creates an object. - D is wrong: a variable that changes is an ordinary variable, so it should be
tax_rate. CapWords would make it look like a class.
Reread 19.5 PEP 8: Naming and Whitespace if you chose differently.
Question 10 · Short answer
List every PEP 8 problem in these lines, and write the tidy version.
def AddBonus( points,bonus = 10 ):
Total=points+bonus
return TotalCheck your reasoning
Model answer. Problems:
AddBonusis a function, so it should be snake_case:add_bonus.- There are spaces just inside the parentheses, and none after the comma.
bonus = 10is a default with no hint, so it takes no spaces around=.Totalis a variable, so it should be snake_case:total.Total=points+bonusneeds spaces around=and+.
def add_bonus(points, bonus=10):
total = points + bonus
return totalA correct answer finds the two naming problems (function and variable) and at least three of the whitespace problems.
Reread 19.5 PEP 8: Naming and Whitespace if your answer differed.
19.6 PEP 8: Line Length and Imports
Two more groups of rules decide how a file looks from a distance: how long its lines are, and how its imports are arranged at the top.
Line length
PEP 8 limits lines to 79 characters (and comments and docstrings to 72). Short lines let you put two files side by side, read code in a narrow window or on a phone, and compare two versions of a file without scrolling sideways. Many teams relax the limit to 88 characters, which is what the formatters in 19.7 use by default. Whichever limit your project chooses, long lines need a way to break.
A line of Python normally ends where the line ends. But inside parentheses, square brackets, or curly braces, a statement can continue onto the next line, because Python knows the statement is not finished until the bracket closes. This is called implicit line continuation. You have already seen it in Day 17’s main.py, where one from tasks import (...) statement ran over several lines.
Worked example: Breaking long lines inside brackets
def can_ship(weight_kg: float, length_cm: float, country: str) -> bool:
"""Return True if a parcel meets every shipping rule."""
return (
weight_kg <= 20
and length_cm <= 120
and country in ["Canada", "Mexico", "United States"]
)
parcels = [
("books", 4.2, 35, "Canada"),
("bike", 14.0, 150, "Mexico"),
]
for name, weight_kg, length_cm, country in parcels:
allowed = can_ship(weight_kg, length_cm, country)
print(
f"{name}: {weight_kg} kg, {length_cm} cm to {country}",
"- OK" if allowed else "- too big or too heavy",
)books: 4.2 kg, 35 cm to Canada - OK
bike: 14.0 kg, 150 cm to Mexico - too big or too heavy- Lines 3 to 7: a long condition is wrapped in parentheses, so it can be split into one test per line. PEP 8 recommends putting each
and(or+, or any operator) at the start of a line, so you can scan down the left edge and see how the pieces combine. The closing)goes on its own line, lined up withreturn. - Lines 10 to 13: a list with one item per line. The comma after the last item is allowed, and it means a new item can be added later by changing only one line.
- Lines 16 to 19: a long
printcall with one argument per line, indented 4 spaces more than the line that opened the parenthesis.
Without the brackets, none of these breaks would be allowed.
Imports
PEP 8’s rules for import statements (Day 13) are:
- Put all imports at the top of the file, after the module’s docstring and before anything else, so a reader sees at once what the file depends on.
- Put one module per import line:
import jsonandimport oson separate lines, notimport json, os. Importing several names from one module,from tasks import add_task, list_tasks, is fine. - Arrange imports in three groups, with a blank line between groups: the standard library first, then third-party packages (installed with pip), then your own modules. Many teams also sort each group alphabetically.
- Avoid
from module import *(Day 13): it hides where names come from.
"""Fetch the weather and save a daily report."""
import json
from datetime import date
from pathlib import Path
import requests
from report import format_report
json, datetime, and pathlib are in the standard library; requests is the third-party package from Day 13 (it is not installed here, so this block is for reading only); report is a module of your own. Seeing the groups, a reader knows at once that this program needs requests installed.
Key idea
Keep lines to 79 characters (or your project’s limit, often 88). Break a long line inside parentheses, brackets, or braces, with operators at the start of each new line. Put imports at the top, one module per line, grouped as standard library, third-party, then your own modules.
Watch out
Breaking a line outside brackets. Without an open bracket, Python sees the end of the line as the end of the statement:
price = 19.99
tax = 1.60
shipping = 4.50
total = price + tax +
shipping
print(f"{total:.2f}") File ".../day19/broken_line.py", line 4
total = price + tax +
^
SyntaxError: invalid syntaxLine 4 ends with + and nothing after it, which is not a complete expression. Wrap the expression in parentheses so it can continue:
price = 19.99
tax = 1.60
shipping = 4.50
total = (
price
+ tax
+ shipping
)
print(f"{total:.2f}")26.09(This expression is short enough for one line, total = price + tax + shipping, which is better; the split only shows the form for a line that is genuinely too long.) Python also lets you end a line with a backslash \ to continue it, but PEP 8 prefers parentheses: a space typed after the backslash breaks it, and you cannot see the mistake.
Pause and try
Question 11 · Multiple choice
A file needs json and pathlib from the standard library, the third-party package requests, and the function load_tasks from the file tasks.py in the same project. Which import block follows PEP 8?
import json, requestson one line, thenfrom pathlib import Path, thenfrom tasks import load_tasksimport json,from pathlib import Path, a blank line,import requests, a blank line,from tasks import load_tasksfrom tasks import load_tasks, a blank line,import requests, a blank line,import json,from pathlib import Pathimport jsonat the top, andimport requestsinside the function that uses it, just before the call
Check your reasoning
B. Standard library first, then third-party, then your own modules, with a blank line between the groups and one module per line.
- A is wrong: it puts two modules on one line, and mixes a standard library module with a third-party one.
- C is wrong: the groups are in reverse order. The standard library comes first.
- D is wrong: imports go at the top of the file, where a reader can see everything the file needs.
Reread 19.6 PEP 8: Line Length and Imports if you chose differently.
Question 12 · Debugging
The author split a long return line to keep it under 79 characters, and now the program will not run. Explain the error and fix it.
def on_honor_roll(average: float, attendance: float, late_days: int) -> bool:
"""Return True if a student qualifies for the honor roll."""
return average >= 90 and attendance >= 0.95
and late_days <= 3
print(on_honor_roll(93.5, 0.97, 1))Check your reasoning
Python stops before running anything:
IndentationError: unexpected indentLine 3 is a complete statement on its own (return average >= 90 and attendance >= 0.95), and there is no open bracket, so Python treats the end of line 3 as the end of the statement. Line 4 then looks like a new statement that is indented for no reason. (Older versions of Python may word the message differently.) Wrap the whole condition in parentheses, with one test per line and each and at the start:
def on_honor_roll(average: float, attendance: float, late_days: int) -> bool:
"""Return True if a student qualifies for the honor roll."""
return (
average >= 90
and attendance >= 0.95
and late_days <= 3
)
print(on_honor_roll(93.5, 0.97, 1))
print(on_honor_roll(93.5, 0.97, 5))True
FalseThe first student meets all three rules. The second has 5 late days, so late_days <= 3 is False and so is the whole and chain. Notice that the broken version, had it run, would have ignored the late-days rule completely.
Reread 19.6 PEP 8: Line Length and Imports if you missed it.
19.7 Formatting and Linting with ruff or black
You could tidy every file by hand with the tables from 19.5 and 19.6, but nobody wants to count spaces, and people miss things. Programmers hand that job to tools, so the people can argue about what the code does instead of where the spaces go. There are two kinds:
- A formatter rewrites the layout of your code automatically: spaces, blank lines, quotes, and line breaks. It never changes what the code does. The best-known Python formatters are black and ruff’s formatter, which produce almost identical results.
- A linter reads your code and reports likely bugs and style problems, without changing anything unless you ask. (The name comes from an old tool called lint, which picked little bits of fluff out of programs.) ruff is a fast, popular linter; older ones include flake8 and pylint.
ruff does both jobs in one tool. Like the type checkers, these are third-party tools that you install with pip, ideally into the project’s virtual environment, and run in the terminal:
python -m pip install ruff
ruff format .
ruff format --check .
ruff check .
ruff check --fix .
| Command | Does |
|---|---|
ruff format . |
Reformats every .py file in this folder and below, in place |
ruff format --check . |
Changes nothing; lists the files that formatting would change |
ruff check . |
Lints every .py file and reports what it finds |
ruff check --fix . |
Lints, and fixes the problems that have a safe automatic fix |
If you prefer black, python -m pip install black installs it and black . reformats every file (black --check . only reports). Black is only a formatter, so pair it with a linter.
Worked example: A formatter at work
Here is what ruff format (or black) does to messy_grades.py from 19.5, saved under a new name so you can compare the two:
MaxScore = 100
def Percent(Score, outOf=MaxScore):
return round(Score / outOf * 100, 1)
class student_record:
def __init__(self, name, score):
self.name = name
self.score = score
def summary(self):
return f"{self.name}: {Percent(self.score)}%"
print(student_record("Ana", 87).summary())Ana: 87.0%Every spacing problem is fixed: spaces around = and operators, after commas, none inside brackets, two blank lines between top-level definitions and one between methods, and double quotes. But the names are untouched: it still says MaxScore, Percent, and student_record. Renaming changes what the code means (every use of a name has to change with it), and a formatter only touches layout. The program prints exactly what it printed before. Most editors can run a formatter every time you save a file, so you never have to think about spacing again.
Worked example: A linter at work
This program runs and prints the right thing:
import json
def find_task(tasks, title):
"""Return the task with this title, or None if there is none."""
count = 0
for task in tasks:
if task["title"] == title:
return task
return None
tasks = [{"title": "Practice piano", "done": False}]
if find_task(tasks, "Swim") == None:
print("No task called Swim")No task called Swimruff check lint_me.py reports two problems anyway. This is sample output in ruff’s short layout (the one ruff check --output-format concise lint_me.py prints); by default ruff shows each problem with the lines of code around it, and the exact layout differs between versions.
lint_me.py:1:8: F401 [*] `json` imported but unused
lint_me.py:6:5: F841 Local variable `count` is assigned to but never used
Found 2 errors.
[*] 1 fixable with `--fix` (1 unsafe fix available with `--fix --unsafe-fixes`).Each line gives the file, the line, the column (the character position along the line), a rule code, and a message; [*] marks a problem that --fix can fix. Codes starting with F are about likely bugs; codes starting with E are about PEP 8 style.
Line 14’s == None breaks a PEP 8 rule too, but ruff’s default rules leave out most E checks. ruff check --select E,F lint_me.py asks for all the E and F rules, and adds a third problem:
lint_me.py:14:32: E711 Comparison to `None` should be `cond is None`| Code | Problem | Fix |
|---|---|---|
| F401 | json is imported but never used: the file depends on something it does not need |
Delete the import |
| F841 | count is set but never used: often a sign of an unfinished idea or a misspelled name |
Delete it, or use it |
| E711 | == None can be fooled by a class whose __eq__ (Day 17) says it equals None; is cannot |
if find_task(tasks, "Swim") is None: |
ruff check --fix deletes the unused import by itself. The other two need a person to decide, because the tool cannot know whether count was meant to be used. Out of the box, ruff turns on only rules that are almost always right; it can check many more, including PEP 8 naming and most E rules, when a project asks for them.
| Tool | What it looks at | Changes your code? | Example |
|---|---|---|---|
| Formatter | Layout: spaces, blank lines, quotes | Yes, layout only | ruff format, black |
| Linter | Likely bugs and style rules | Only with --fix |
ruff check |
| Type checker (19.4) | Values against type hints | No | mypy, pyright |
Key idea
A formatter, such as ruff format or black, rewrites the layout of your code to the standard style without changing
what it does. A linter, such as ruff check, reports likely bugs and style problems by rule code, and can fix some of
them. Neither replaces the other, or a type checker.
Watch out
Thinking a clean format means correct code. A formatter fixed every space in messy_grades.py and left every bad name in place; it would also leave a wrong formula, a swapped argument, or a missing None check exactly as they were. Formatting makes code readable; linting and type checking find some bugs; none of them can tell whether the program computes the right answer. Use all three, and still test your code (Day 20).
Pause and try
Question 13 · Multiple choice
You run ruff format . on a project. Which change can it make?
- Renaming the function
CalcTotaltocalc_totaleverywhere it is used - Deleting an import that is never used
- Changing
total=price*qtytototal = price * qty - Changing
-> intto-> stron a function that returns a string
Check your reasoning
C. Adding spaces is a layout change, and layout is all a formatter changes.
- A is wrong: renaming changes what the names mean, and a formatter never touches names. PEP 8 naming problems need a person (or a linter with naming rules turned on, to report them).
- B is wrong: deleting an unused import is a linter’s fix (
ruff check --fix), not a formatter’s. - D is wrong: no tool here rewrites your hints. A type checker would report the mismatch, and you decide whether to fix the hint or the code.
Reread 19.7 Formatting and Linting with ruff or black if you chose differently.
Question 14 · Short answer
A teammate’s project has no formatter or linter. Give two reasons to add both, and say why both are needed rather than just one.
Check your reasoning
Model answer: a formatter means nobody spends time on spacing or argues about it in a review, and every file looks the same, so readers can concentrate on what the code does. A linter catches likely bugs that still run, such as an unused import or a variable that is set and never used, and style problems such as == None. Both are needed because they do different jobs: the formatter changes layout but never reports bugs or renames anything, and the linter reports problems but (apart from a few safe fixes) does not reformat code.
A correct answer gives one benefit of each tool and explains that the formatter handles layout while the linter finds problems.
Reread 19.7 Formatting and Linting with ruff or black if your reasoning differed.
19.8 Docstrings and Comments That Help
Type hints now say what type each value is. A reader can still have questions that no hint answers. Is the price in dollars or cents? Can people be 0? What happens if it is? Does the result include the tip? Docstrings (Day 9) and comments (Day 1) are where you answer those questions, and the best ones answer exactly what the code cannot say for itself.
Docstrings
A docstring is for someone using the function. Day 9 gave it a one-line summary; a useful docstring follows a few more conventions from PEP 257, the PEP about docstrings:
- The first line is a one-sentence summary that ends with a period and is written as a command: “Return the share…”, not “This function returns the share…”.
- For a function whose parameters need explaining, a blank line follows, then the details: what each parameter means (units, allowed values), what is returned, including any
Nonecase, and which exceptions it raises (Day 15). - It does not repeat the hints.
total: floatalready says it is a float; the docstring says it is the whole bill, in dollars, with the tip included.
Worked example: A docstring that answers the caller's questions
def split_bill(total: float, people: int = 2) -> float:
"""Return each person's share of a bill, rounded to cents.
Args:
total: The whole bill in dollars, tip included.
people: How many people share it. Must be at least 1.
Returns:
One person's share, rounded to 2 decimal places.
Raises:
ValueError: If people is less than 1.
"""
if people < 1:
raise ValueError(f"people must be at least 1, not {people}")
return round(total / people, 2)
print(split_bill(85.0, 3))
help(split_bill)28.33
Help on function split_bill in module __main__:
split_bill(total: float, people: int = 2) -> float
Return each person's share of a bill, rounded to cents.
Args:
total: The whole bill in dollars, tip included.
people: How many people share it. Must be at least 1.
Returns:
One person's share, rounded to 2 decimal places.
Raises:
ValueError: If people is less than 1.The layout with Args:, Returns:, and Raises: headings is a common convention (often called Google style); many tools and editors recognize it. Together with the hints in the signature, help() now tells a caller everything: what to pass, what each value means, what comes back, and what can go wrong, without reading lines 14 to 16. 85.0 / 3 is 28.333…, rounded to 28.33.
A one-line docstring is enough when the name, the hints, and one sentence say it all, as in most of today’s examples. Write the longer form when a caller would otherwise have to read the body.
Comments
A comment is for someone reading the code, usually someone about to change it. The code already says what it does; a good comment says why: a reason, a rule from outside the program, or a trap the next person might fall into.
TAX_RATE = 0.0825 # 6.25% state tax plus 2% city tax.
def price_with_tax(price: float) -> float:
"""Return price with sales tax added, rounded to cents."""
# Round once, at the end: rounding the tax and the price separately
# can leave the total a cent out.
return round(price * (1 + TAX_RATE), 2)
def task_title(tasks: list[str], number: int) -> str:
"""Return the title of task number, counting from 1."""
# People number tasks from 1, but list indexes start at 0.
return tasks[number - 1]
print(price_with_tax(19.99))
print(task_title(["Practice piano", "Water plants"], 2))21.64
Water plants
Each comment here tells the reader something the code cannot. Line 1’s comment explains where the odd number 0.0825 comes from. PEP 8 puts an inline comment (one on the same line as code) at least two spaces after the code, with one space after the #. Lines 6 and 7 are a block comment, on their own lines above the code they explain, indented to match it; they warn the next person not to “improve” the code by rounding twice. Line 13 explains the - 1 that would otherwise look like a bug. Compare comments that only repeat the code:
count = count + 1 # add 1 to count
for task in tasks: # loop over the tasks
These cost reading time and tell the reader nothing new. Leave them out. If a line needs a comment to say what it does, first try a clearer name: seconds_per_day = 86400 needs no comment, where n = 86400 # seconds in a day does.
Key idea
A docstring tells a caller what the function does, what each parameter means beyond its type, what it returns, and what it raises, starting with a one-line command such as “Return …”. A comment tells a reader why the code is the way it is; never use it to repeat what the code already says.
Watch out
Leaving a comment that no longer matches the code. Someone changes the code and forgets the comment:
TAX_RATE = 0.0825 # 6% state tax.Now the reader has two answers and no way to know which is true. PEP 8 puts it plainly: “Comments that contradict the code are worse than no comments.” Whenever you change a line, update or delete the comments and docstring that describe it. The same goes for commented-out code (Day 1) left in a file after it stopped being useful: delete it, because the next reader cannot tell whether it is supposed to come back.
Pause and try
Question 15 · Multiple choice
Which comment is the most useful?
index = number - 1 # subtract 1 from numberindex = number - 1 # people count tasks from 1, lists from 0index = number - 1 # this line works out the indexindex = number - 1 # TODO: check this
Check your reasoning
B. It explains why the code subtracts 1, which is the one thing the code cannot say for itself, and it stops a future reader from “fixing” what looks like a mistake.
- A is wrong: it repeats exactly what the code says.
- C is wrong: it also repeats the code, in vaguer words; the name
indexalready says that. - D is wrong: it raises a doubt without saying what to check or why, so the next reader knows less than before.
Reread 19.8 Docstrings and Comments That Help if you chose differently.
Question 16 · Short answer
Improve this function’s docstring. Say what the hints already tell the reader, and what the docstring still needs to add.
def pace(distance: float, minutes: float) -> float:
"""This function takes a float and a float and returns a float."""
return minutes / distanceCheck your reasoning
Model answer: the hints already say that both parameters and the result are floats, so the docstring repeats them and says nothing else. It should say what the function computes, which units it uses, and what happens with a distance of 0. For example:
def pace(distance: float, minutes: float) -> float:
"""Return a running pace in minutes per kilometer.
Args:
distance: How far the run was, in kilometers. Must not be 0.
minutes: How long the run took, in minutes.
Returns:
The minutes taken for each kilometer.
"""
return minutes / distanceA correct answer starts with a one-line command (“Return …”), names the units, and does not just repeat the types. Mentioning that a distance of 0 would cause a ZeroDivisionError (Day 15) is a strong addition.
Reread 19.8 Docstrings and Comments That Help if your answer differed.
Putting it together
A reader keeps a log of the books they finish, with an optional rating from 1 to 5. The program below uses every idea from today: hints on every function, a field and a return value that might be None, nested collection hints, PEP 8 names and layout, and docstrings and a comment that answer the reader’s questions.
"""Keep a log of finished books and report on a reader's progress."""
from dataclasses import dataclass
PAGE_GOAL = 1000
@dataclass
class Book:
"""One finished book. rating is None until the reader rates it."""
title: str
pages: int
rating: int | None = None
def total_pages(books: list[Book]) -> int:
"""Return the number of pages read across all books."""
return sum(book.pages for book in books)
def average_rating(books: list[Book]) -> float | None:
"""Return the average rating of the rated books.
Returns None when no book has a rating yet, because an average of
no numbers has no sensible value (and would divide by zero).
"""
ratings = [book.rating for book in books if book.rating is not None]
if not ratings:
return None
return sum(ratings) / len(ratings)
def titles_by_rating(books: list[Book]) -> dict[int, list[str]]:
"""Group the titles of rated books by their rating."""
groups: dict[int, list[str]] = {}
for book in books:
if book.rating is None:
continue
if book.rating not in groups:
groups[book.rating] = []
groups[book.rating].append(book.title)
return groups
def report(books: list[Book]) -> None:
"""Print a short progress report for a list of finished books."""
pages = total_pages(books)
print(f"Pages read: {pages} of {PAGE_GOAL}")
average = average_rating(books)
if average is None:
print("No ratings yet")
else:
print(f"Average rating: {average:.1f}")
groups = titles_by_rating(books)
for rating in sorted(groups, reverse=True):
titles = groups[rating]
print(f"{rating} stars: {', '.join(titles)}")
log = [
Book("The Hobbit", 310, 5),
Book("Holes", 233, 4),
Book("Wonder", 316),
Book("Matilda", 240, 5),
]
report(log)
print()
report([Book("Coraline", 162)])Pages read: 1099 of 1000
Average rating: 4.7
5 stars: The Hobbit, Matilda
4 stars: Holes
Pages read: 162 of 1000
No ratings yet
| Lines | What to notice | Idea |
|---|---|---|
| 1 | A module docstring says what the whole file is for | 19.8 |
| 3–5 | Imports at the top, then a constant in UPPER_CASE |
19.5, 19.6 |
| 8–14 | A data class whose rating field is int | None, defaulting to None; the class docstring explains what None means |
19.3 |
| 17 | list[Book] takes a list of objects of your own class |
19.2 |
| 22–31 | float | None: the docstring explains why the function can return None |
19.3, 19.8 |
| 34 | A nested return hint, dict[int, list[str]]: from a rating to a list of titles |
19.2 |
| 36 | A variable annotation for a dictionary that starts empty | 19.2 |
| 50–54 | The caller checks is None before formatting the average with :.1f |
19.3, 19.4 |
Follow the first report. total_pages adds 310 + 233 + 316 + 240 = 1099. average_rating collects only the ratings that are not None: 5, 4, and 5 (Wonder has no rating), so the average is 14 / 3 = 4.67, shown as 4.7. titles_by_rating skips Wonder and builds {5: ["The Hobbit", "Matilda"], 4: ["Holes"]}, and report prints the ratings from highest to lowest. In the second report, Coraline has no rating, so ratings is empty and average_rating returns None instead of dividing by zero; report checks for it on line 51 and prints No ratings yet. A type checker run on this file finds nothing to report, and a formatter would leave it unchanged, because it already follows the style that formatters produce.
Common mistakes
Today’s tools report problems by message and code. Use this table to go from a report to its usual cause. Messages from the tools are samples; their wording varies by version.
| Report | From | Usual cause | Fix |
|---|---|---|---|
TypeError: unsupported operand type(s) for *: 'float' and 'type' |
Python | price=float instead of price: float |
Use a colon for a hint |
TypeError: ... 'NoneType' and 'int' |
Python | A maybe-None value used without a check |
Check is None first |
Incompatible return value type (got "str", expected "int") |
mypy | The function returns a different type from its hint | Fix the code, or the hint if it is wrong |
Argument 1 to "label" has incompatible type ... |
mypy | Wrong argument, often arguments in the wrong order | Pass the right value |
Item "None" of "Task | None" has no attribute ... |
mypy | A maybe-None value used without a check |
Check is None first |
TypeError: 'list' object is not callable |
Python | A variable named after a built-in | Rename the variable |
SyntaxError or IndentationError on a split line |
Python | A line broken outside brackets | Wrap the expression in parentheses |
F401 ... imported but unused |
ruff check | An import nothing uses | Delete it (--fix can) |
F841 Local variable ... is assigned to but never used |
ruff check | A leftover or misspelled variable | Delete it, or use it |
E711 Comparison to None ... |
ruff check | == None (reported with --select E) |
is None |
Older versions of Python may word the Python messages differently.
Vocabulary
| Term | Definition | Example |
|---|---|---|
| type annotation | A note on a parameter, return value, or variable, written after a colon or after -> |
price: float |
| type hint | An annotation that states the type a value should have; Python does not check it at run time | def area(width: float) -> float: |
-> None |
The return hint for a function that returns nothing useful | def show_menu() -> None: |
| variable annotation | A type hint on a variable, written before its first value | counts: dict[str, int] = {} |
| union type | A hint meaning “one of these types”, written with | |
int | str |
X | None |
A value of type X, or None (older code: Optional[X]) |
-> Task | None |
typing |
The standard library module of extra hint tools, such as Optional and List |
from typing import Optional |
| type checker | A tool that reads code without running it and reports values that do not match the hints | mypy checkout.py |
| static analysis | Checking code by reading it, without running it | type checking, linting |
| PEP | A Python Enhancement Proposal: a numbered document proposing a change or a convention | PEP 8, PEP 257 |
| PEP 8 | Python’s official style guide | snake_case functions |
| implicit line continuation | Continuing a statement on the next line inside an open bracket | return ( … ) |
| formatter | A tool that rewrites code’s layout to a standard style without changing what it does | ruff format ., black . |
| linter | A tool that reports likely bugs and style problems | ruff check . |
| rule code | A linter’s short name for one rule | F401 |
| PEP 257 | The conventions for writing docstrings | “Return …” summaries |
| inline comment | A comment on the same line as code, two spaces after it | x = 1 # why |
| block comment | A comment on its own lines, indented like the code it explains | # Round once, at the end: ... |
Summary
- A type hint says what type a value should have:
name: typeon parameters and variables,-> typefor the return value, with-> Nonewhen nothing is returned. Python stores hints but never checks them when the program runs. - Collection hints name the inner types:
list[int],set[str],dict[str, int](keys, then values),tuple[str, int](one type per position), nested as deep as the data.X | Nonemeans “an X, orNone”, and the caller should checkis Nonebefore using the value. - A type checker such as mypy or pyright reads hinted code without running it and reports mismatches, with the line, the type found, and the type expected, so it finds bugs in lines that have not run yet.
- PEP 8 is the shared style guide:
snake_casefor functions and variables,CapWordsfor classes,UPPER_CASEfor constants, single spaces around operators, 79-character lines broken inside brackets, and grouped imports at the top. - A formatter (
ruff format,black) applies the layout rules for you; a linter (ruff check) reports likely bugs and style problems. A docstring tells a caller what a function does, means, returns, and raises; a comment tells a reader why.
Practice
Do these in your day19 folder. Type each program yourself, run it, and only then open the answer. Questions 18 and 19 use ruff and mypy (or pyright); install them first, as shown in 19.7 and 19.4.
Question 17 · Short answer
A typed to-do manager. Copy your to-do manager from Day 17 (main.py, task.py, tasks.py, and tasks.json) into a todo_app folder in day19. Add type hints to every function and method: every parameter except self, and every return value. Then run main.py and check that it behaves exactly as before.
Check your reasoning
One possible solution. task.py:
"""The Task class for the to-do manager, written as a data class."""
from dataclasses import asdict, dataclass
@dataclass
class Task:
"""One thing to do: a title, and whether it is done yet."""
title: str
done: bool = False
def complete(self) -> None:
"""Mark this task as done."""
self.done = True
def to_dict(self) -> dict[str, str | bool]:
"""Return this task as a dictionary, ready to save as JSON."""
return asdict(self)
def __str__(self) -> str:
mark = "x" if self.done else " "
return f"[{mark}] {self.title}"
if __name__ == "__main__":
task = Task("Test the class")
print(repr(task))
task.complete()
print(task)
print(task.to_dict())
print(task == Task("Test the class", True))Task(title='Test the class', done=False)
[x] Test the class
{'title': 'Test the class', 'done': True}
Truetasks.py:
"""Task functions for the to-do manager. main.py imports them."""
import json
from pathlib import Path
from task import Task
def show_menu() -> None:
"""Print the menu of choices."""
print()
print("1) Add 2) List 3) Complete 4) Delete 5) Quit")
def add_task(tasks: list[Task], title: str) -> None:
"""Add a new, unfinished Task to the end of tasks."""
tasks.append(Task(title))
def list_tasks(tasks: list[Task]) -> None:
"""Print every task with its number, using Task.__str__."""
if not tasks:
print("No tasks yet.")
return
for number, task in enumerate(tasks, 1):
print(f"{number}. {task}")
def complete_task(tasks: list[Task], number: int) -> Task | None:
"""Mark task number (counting from 1) as done.
Return the Task, or None if no task has that number.
"""
if not 1 <= number <= len(tasks):
return None
task = tasks[number - 1]
task.complete()
return task
def delete_task(tasks: list[Task], number: int) -> Task | None:
"""Remove task number (counting from 1) from tasks.
Return the removed Task, or None if no task has that number.
"""
if not 1 <= number <= len(tasks):
return None
return tasks.pop(number - 1)
def save_tasks(tasks: list[Task], path: Path) -> None:
"""Write tasks to the JSON file at path, replacing what was there."""
data = [task.to_dict() for task in tasks]
with path.open("w", encoding="utf-8") as file:
json.dump(data, file, indent=2)
def load_tasks(path: Path) -> list[Task]:
"""Return the Tasks saved at path, or [] if there are none to load.
A missing file means this is the first run. A file that is not valid
JSON gets a warning, and the program starts with no tasks.
"""
try:
with path.open(encoding="utf-8") as file:
data = json.load(file)
except FileNotFoundError:
return []
except json.JSONDecodeError:
print(f"Warning: {path.name} is not valid JSON, so no tasks loaded.")
return []
return [Task(**item) for item in data]
if __name__ == "__main__":
test_tasks: list[Task] = []
add_task(test_tasks, "Test the module")
task = complete_task(test_tasks, 1)
print(task is not None and task.done)
print(complete_task(test_tasks, 2))
list_tasks(test_tasks)True
None
1. [x] Test the moduleIn main.py, the only change is the first line of the function: def main() -> None:. The menu session is exactly Day 17’s, because hints never change what a program does.
How it works:
- Functions that only print or change a list, such as
show_menu,add_task, andsave_tasks, return nothing, so their hint is-> None. complete_taskanddelete_taskreturn aTaskorNone(19.3), andmain.pyalready checksif task is None:before using the result.tasks: list[Task]appears on every function that takes the list (19.2). TheTaskclass fromtask.pyis used as a type like any built-in type.save_tasksandload_tasksreceive apathlib.Pathfrommain.py, sotasks.pynow importsPath(line 4) just to use it in the hints.to_dictreturns a dictionary whose keys are strings and whose values are a string (the title) or a bool (done):dict[str, str | bool].selfgets no hint: it is always an object of the class itself.- Line 76 annotates the empty list that the test code starts with. Lines 78 and 79 store
complete_task’s result and check it before reading.done; Day 17’scomplete_task(test_tasks, 1).donewould be reported by a type checker now that the hint says the result might beNone(19.4).
Reread 19.1 Annotating Parameters and Return Values, 19.2 Hints for Lists, Dictionaries, and Tuples, and 19.3 Values That Might Be None if your hints differed.
Question 18 · Short answer
Lint and format. Run a linter and a formatter on your project and fix what they report. If your own code comes back clean, practice on this quick to-do script, written in a hurry: save it as todo_messy.py, run ruff check todo_messy.py, fix what it reports, then run ruff format todo_messy.py, and finally fix anything else PEP 8 asks for that neither tool changed.
import json, os
def addTask(tasks,title):
tasks.append({"title":title,"done":False})
def countDone(tasks):
total=0
done = 0
for t in tasks:
if t['done']==True: done+=1
return done
tasks=[]
addTask(tasks,'Practice piano')
addTask(tasks,'Water plants')
tasks[0]['done']=True
print(countDone(tasks),'of',len(tasks),'done')Check your reasoning
The linter’s report looks something like this (sample output in the short layout of 19.7; the layout and wording vary by version):
todo_messy.py:1:1: I001 [*] Import block is un-sorted or un-formatted
todo_messy.py:1:8: F401 [*] `json` imported but unused
todo_messy.py:1:14: F401 [*] `os` imported but unused
todo_messy.py:5:5: F841 Local variable `total` is assigned to but never used
Found 4 errors.
[*] 3 fixable with `--fix` (1 unsafe fix available with `--fix --unsafe-fixes`).Fixes: delete the import line (nothing uses json or os, and I001 objects to two modules on one line, which also breaks 19.6’s rule) and delete total = 0. Then ruff format fixes the spacing, quotes, and blank lines, and moves done += 1 onto its own line. Neither tool touches == True or renames anything, so do those by hand: write if task["done"]:, because a bool is already true or false (Day 4), and rename addTask to add_task, countDone to count_done, and the one-letter t to task. Adding a docstring to each function finishes the job. One possible result:
def add_task(tasks, title):
"""Add a new, unfinished task to the end of tasks."""
tasks.append({"title": title, "done": False})
def count_done(tasks):
"""Return how many tasks are done."""
done = 0
for task in tasks:
if task["done"]:
done += 1
return done
tasks = []
add_task(tasks, "Practice piano")
add_task(tasks, "Water plants")
tasks[0]["done"] = True
print(count_done(tasks), "of", len(tasks), "done")1 of 2 doneHow it works: the output is the same before and after, because none of these fixes changes what the program does; they make it shorter, clearer, and free of the leftover imports and variable. Run ruff check and ruff format --check again at the end: both should report nothing. On your typed todo_app from Question 17, both tools usually find nothing, because it already follows the style they enforce.
Reread 19.5 PEP 8: Naming and Whitespace and 19.7 Formatting and Linting with ruff or black if your result differed.
Question 19 · Short answer
Resolve type checker complaints. Run a type checker on your project and resolve at least three complaints. If your typed todo_app passes cleanly, practice on this file, which has three type mistakes: save it as todo_check.py, run mypy todo_check.py (or pyright todo_check.py), and fix each complaint.
from dataclasses import dataclass
@dataclass
class Task:
title: str
done: bool = False
def add_task(tasks: list[Task], title: str) -> None:
tasks.append(Task(title))
def complete_task(tasks: list[Task], number: int) -> Task:
if not 1 <= number <= len(tasks):
return None
task = tasks[number - 1]
task.done = True
return task
def find_task(tasks: list[Task], title: str) -> Task | None:
for task in tasks:
if task.title == title:
return task
return None
tasks: list[Task] = []
add_task(tasks, "Practice piano")
add_task(tasks, Task("Water plants"))
complete_task(tasks, 1)
print(find_task(tasks, "Practice piano").done)
print(tasks[1])Check your reasoning
The program runs, but its last line already shows something is wrong:
True
Task(title=Task(title='Water plants', done=False), done=False)mypy reports three errors (sample output; wording varies by version, and pyright words them differently but points to the same lines):
todo_check.py:16: error: Incompatible return value type (got "None", expected "Task") [return-value]
todo_check.py:31: error: Argument 2 to "add_task" has incompatible type "Task"; expected "str" [arg-type]
todo_check.py:33: error: Item "None" of "Task | None" has no attribute "done" [union-attr]
Found 3 errors in 1 file (checked 1 source file)The fixes, one per complaint:
- Line 16:
complete_taskreally can returnNone, so the code is right and the hint is wrong. Change line 14’s return hint to-> Task | None. - Line 31:
add_taskexpects a title and makes theTaskitself, so passing aTaskwrapped one task inside another; that is the strange last line of output. Pass the title:add_task(tasks, "Water plants"). - Line 33:
find_taskmight returnNone. Store the result and check it before reading.done.
from dataclasses import dataclass
@dataclass
class Task:
title: str
done: bool = False
def add_task(tasks: list[Task], title: str) -> None:
tasks.append(Task(title))
def complete_task(tasks: list[Task], number: int) -> Task | None:
if not 1 <= number <= len(tasks):
return None
task = tasks[number - 1]
task.done = True
return task
def find_task(tasks: list[Task], title: str) -> Task | None:
for task in tasks:
if task.title == title:
return task
return None
tasks: list[Task] = []
add_task(tasks, "Practice piano")
add_task(tasks, "Water plants")
complete_task(tasks, 1)
task = find_task(tasks, "Practice piano")
if task is not None:
print(task.done)
print(tasks[1])True
Task(title='Water plants', done=False)How it works: running the checker again reports no issues. Each fix is a different kind: a wrong hint (1), a wrong argument (2), and a missing None check (3). Complaint 2 was a real bug that the output showed only if you looked closely; complaint 3 was a crash waiting for the day someone searches for a title that does not exist.
Reread 19.4 Checking Types with mypy or pyright and 19.3 Values That Might Be None if your result differed.
Review questions
Question 20 · Multiple choice
A function best_score(scores) takes a dictionary from student names to lists of test scores (whole numbers), and returns the name and score of the highest single score, or None if the dictionary is empty. Which def line has the right hints?
def best_score(scores: dict[str, list[int]]) -> tuple[str, int] | None:def best_score(scores: dict[str, int]) -> tuple[str, int]:def best_score(scores: dict[str, list[int]]) -> tuple[str, int]:def best_score(scores: list[int]) -> str | int | None:
Check your reasoning
A. The parameter maps names (strings) to lists of ints, and the result is either a (name, score) pair, which is tuple[str, int], or None, so the whole return type is tuple[str, int] | None.
- B is wrong twice: each value is a list of scores, not one int, and the hint leaves out the
Nonecase. - C is wrong: it promises a pair every time, so callers would not know to check for
Nonewhen the dictionary is empty. - D is wrong: the parameter is a dictionary, not a list, and
str | int | Nonemeans “a single string, int, orNone”, not a pair of a name and a score.
Reread 19.2 Hints for Lists, Dictionaries, and Tuples and 19.3 Values That Might Be None if you chose differently.
Question 21 · Debugging
This program should print each class’s average, or no scores for a class with none. It prints the first average and then crashes. Explain the error, say which tool could have warned you before running it, and fix it.
def average(scores: list[int]) -> float | None:
"""Return the average of scores, or None if there are no scores."""
if not scores:
return None
return sum(scores) / len(scores)
for scores in [[80, 95, 90], []]:
print(round(average(scores), 1))Check your reasoning
It prints 88.3 and then stops with:
TypeError: type NoneType doesn't define __round__ methodFor the empty list, average returns None, exactly as its hint float | None says, and round(None, 1) fails because None is not a number. (Older versions of Python may word the message differently.) A type checker would have reported line 9 without running the program, because it passes a value that might be None to round, which needs a number. Check for None before rounding:
def average(scores: list[int]) -> float | None:
"""Return the average of scores, or None if there are no scores."""
if not scores:
return None
return sum(scores) / len(scores)
for scores in [[80, 95, 90], []]:
result = average(scores)
if result is None:
print("no scores")
else:
print(round(result, 1))88.3
no scores(80 + 95 + 90) / 3 = 88.33, rounded to 88.3.
Reread 19.3 Values That Might Be None and 19.4 Checking Types with mypy or pyright if you missed it.
Question 22 · Tracing
What does this program print? One line breaks a hint; decide what Python does with it.
from dataclasses import dataclass
@dataclass
class Player:
name: str
points: int = 0
team: str | None = None
def label(self) -> str:
if self.team is None:
return f"{self.name} ({self.points})"
return f"{self.name} of {self.team} ({self.points})"
def leader(players: list[Player]) -> Player | None:
best: Player | None = None
for player in players:
if best is None or player.points > best.points:
best = player
return best
players = [Player("Ana", 12, "Owls"), Player("Ben", 15), Player("Cy", "9")]
print(players[2].points * 2)
top = leader(players[:2])
if top is not None:
print(top.label())
print(leader([]))Check your reasoning
99
Ben (15)
None- Line 24 makes
Player("Cy", "9"). The field hint sayspoints: int, but Python does not check hints, sopointsholds the string"9". Line 25 doubles it with string repetition (Day 3):"99", printed as99. A type checker would report line 24. - Line 26:
leaderlooks at Ana (12) and Ben (15) only.beststarts asNone, the first player becomesbest, and Ben’s 15 beats 12. Ben has no team, solabeltakes theis Nonebranch on line 11:Ben (15). - Line 29: with an empty list, the loop never runs, and
leaderreturnsNone, which matches its hintPlayer | None.
Reread 19.1 Annotating Parameters and Return Values and 19.3 Values That Might Be None if your output differed.
Question 23 · Short answer
For each problem, say which tool would find or fix it: a formatter, a linter, a type checker, or none of them. (a) x=y+1 with no spaces; (b) import os in a file that never uses os; (c) calling label(3, "pens") when the hints are label(item: str, quantity: int); (d) a function that adds a 5% discount when the shop’s rule is 10%.
Check your reasoning
Model answer:
- (a) A formatter. Spacing is layout, and
ruff formatorblackrewrites it tox = y + 1. - (b) A linter.
ruff checkreports F401 (imported but unused), and--fixcan delete the line. - (c) A type checker. mypy or pyright reports that the arguments do not match the hinted types.
- (d) None of them. The types are right and the layout is fine; only a person, or a test that checks the answer (Day 20), can know that the rule is 10%.
A correct answer matches each problem to the right tool, and recognizes that a wrong rule is invisible to all three.
Reread 19.7 Formatting and Linting with ruff or black and 19.4 Checking Types with mypy or pyright if your reasoning differed.
Question 24 · Multiple choice
Which def line follows PEP 8 and has correct hints?
def pay(hours:float, rate:float=15.0)->float:def pay(hours: float, rate: float = 15.0) -> float:def pay(hours: float, rate: float=15.0) -> float:def Pay(hours: float, rate: float = 15.0) -> float:
Check your reasoning
B. One space after each colon, spaces around = because rate has a hint, spaces around ->, and a snake_case function name.
- A is wrong: it has no spaces after the colons or around
=and->. Python accepts it, but a formatter would rewrite it to B. - C is wrong: without a hint, a default takes no spaces (
rate=15.0), but with a hint it takes a space on each side. - D is wrong:
Payis in CapWords, which is for classes; a function name ispay.
Reread 19.5 PEP 8: Naming and Whitespace and 19.1 Annotating Parameters and Return Values if you chose differently.
Question 25 · Short answer
A new teammate will use your load_tasks(path: Path) -> list[Task] function without reading its body. Using today’s ideas, list what the signature already tells them, and what the docstring must still tell them for them to use it without asking you any questions.
Check your reasoning
Model answer. The signature says: pass a Path, and you get back a list of Task objects. The hints say nothing about the cases a caller has to plan for, so the docstring must say:
- what the file should contain (a JSON list of tasks saved by
save_tasks); - what happens when the file does not exist (an empty list, because it is the first run);
- what happens when the file is not valid JSON (a warning is printed, and an empty list is returned), and that no exception escapes to the caller.
Day 17’s docstring for load_tasks covers the second and third points, which is why main.py needs no try around the call. A correct answer separates what the hints say (types) from what only the docstring can say (meaning, special cases, and errors).
Reread 19.8 Docstrings and Comments That Help if your reasoning differed.
Go further
PEP 8 itself is worth skimming: PEP 8: Style Guide for Python Code explains the reason behind each rule, with many short examples. The typing module documentation introduces the hint tools beyond today’s, for when you meet them in other people’s code.
Reflection
Check each statement. If the answer is “not yet,” reread the linked subsection.
- I can add type hints to parameters and return values, including -> None, and explain that Python does not check them. Reread
- I can write hints such as list[int], dict[str, int], and tuple[str, int], and annotate a variable that starts empty. Reread
- I can use X | None for a value that might be None, and check it with is None before using it. Reread
- I can run mypy or pyright, read what it reports, and fix the code or the hint. Reread
- I can name variables, functions, classes, and constants the PEP 8 way, and space code the PEP 8 way. Reread
- I can break a long line inside brackets, and arrange imports in PEP 8 order. Reread
- I can run a formatter and a linter, and explain what each one does and does not change. Reread
- I can write a docstring that tells a caller what they need, and comments that explain why. Reread