Make a day15 folder for today’s files, open it in your editor, and run each script from its terminal, as on earlier days.
Since Day 1 your programs have stopped with error messages: a NameError for a misspelled name, a ValueError when int() got a word, a ZeroDivisionError when a count was 0. Each time, the fix was to change the code. But some errors are not the programmer’s fault. A user types twelve where you asked for a number. A save file gets deleted, or half written when the laptop’s battery dies. A program cannot prevent those, so it must be ready for them. Days 3 and 5 promised that today you would learn how. By the end of today your programs will answer bad input with a clear message instead of a crash, and your to-do manager will survive a missing or broken save file.
15.1 Reading a Traceback from the Bottom Up
Before you can handle an error, you need to read what Python tells you about it. On Day 1 every error came from one line in the main part of a file. Since Day 9 your programs call functions, and functions call other functions, so an error can happen several calls deep. The report then gets longer.
When a running program hits a problem it cannot get past, Python raises an exception: it stops what it was doing and creates an exception, a value that describes what went wrong. If nothing in the program deals with the exception, the program stops and prints a traceback, the report you met on Day 1. Its name is literal: it traces back through every function call that was in progress when the error happened.
Worked example: An error three calls deep
This program prints the average price of each day’s receipts. Tuesday has no receipts.
def average(prices):
return sum(prices) / len(prices)
def report(day, prices):
print(f"{day}: {average(prices):.2f}")
report("Monday", [4.5, 3.0, 6.0])
report("Tuesday", [])
report("Wednesday", [2.0, 5.0])Monday: 4.50
Traceback (most recent call last):
File ".../day15/receipt_average.py", line 10, in <module>
report("Tuesday", [])
~~~~~~^^^^^^^^^^^^^^^
File ".../day15/receipt_average.py", line 6, in report
print(f"{day}: {average(prices):.2f}")
~~~~~~~^^^^^^^^
File ".../day15/receipt_average.py", line 2, in average
return sum(prices) / len(prices)
~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zeroThe traceback lists the calls in the order they were made, with the most recent call last (that is what its first line says). Each call gets its own entry, called a frame: a File ..., line N, in name line saying where, then the line of code itself. Read it from the bottom up:
| Read | Lines | What it tells you |
|---|---|---|
| 1st | ZeroDivisionError: division by zero |
What went wrong: the kind of exception and its message |
| 2nd | line 2, in average and the code under it |
Where it happened: the line that was running when it failed |
| 3rd | line 6, in report |
Who called that: report was in the middle of calling average |
| 4th | line 10, in <module> |
Who called that: the main part of the file was calling report |
| Last | Traceback (most recent call last): |
Only the heading |
Under each line of code, the marks show the part that failed. The ^ carets point at the exact operation (here the /), and the ~ tildes underline the rest of the expression around it. Monday: 4.50 printed first, because line 9 ran without trouble. Line 11 never ran: once an exception stops the program, nothing after it happens.
So the bottom frame says where the program crashed, and the frames above it say how it got there. You need both, because the line that crashed is not always the line with the bug, as the Watch Out below shows.
The same reading works when the error happens inside one of Python’s built-in functions. Here the text "four" reaches int() two calls deep:
def parse_guests(text):
return int(text)
def split_bill(total, guests_text):
guests = parse_guests(guests_text)
return total / guests
print(split_bill(60, "4"))
print(split_bill(60, "four"))15.0
Traceback (most recent call last):
File ".../day15/split_bill.py", line 11, in <module>
print(split_bill(60, "four"))
~~~~~~~~~~^^^^^^^^^^^^
File ".../day15/split_bill.py", line 6, in split_bill
guests = parse_guests(guests_text)
File ".../day15/split_bill.py", line 2, in parse_guests
return int(text)
ValueError: invalid literal for int() with base 10: 'four'
The bottom line is the Day 2 ValueError. The bottom frame is parse_guests, line 2, where int(text) ran. One frame up, split_bill passed it guests_text, and one more frame up, line 11 is where "four" came from. Python does not always draw the marks; when they would just underline the whole expression on the line, as in return int(text), it leaves them out.
Key idea
Read a traceback from the bottom up: the last line says what went wrong, the bottom frame says where the program stopped, and each frame above says which call led there. The cause can be in any of the frames.
Watch out
Fixing the bottom line without asking how it got there. Faced with ZeroDivisionError on line 2 of receipt_average.py, it is tempting to change average. But average has no sensible answer for an empty list, and silently returning 0 would print Tuesday: 0.00, an average of receipts that never existed. The useful question is the one the upper frames answer: who sent an empty list, and what should happen then? Maybe report should print Tuesday: no receipts instead. Read every frame before you decide where the fix belongs.
Error messages change a little between versions of Python. If yours are worded differently or show no ~ and ^ marks, the last line still names the same exception.
Pause and try
Question 1 · Tracing
What does this program print? Give the output and the last line of the traceback, and name the function that was running when the program stopped.
def lookup(prices, item):
return prices[item]
def basket_total(prices, items):
total = 0
for item in items:
total += lookup(prices, item)
return total
prices = {"apple": 0.5, "bread": 2.25}
print(basket_total(prices, ["apple", "bread"]))
print(basket_total(prices, ["apple", "milk"]))Check your reasoning
2.75
Traceback (most recent call last):
...
KeyError: 'milk'The first basket adds 0.5 and 2.25, so line 13 prints 2.75. In the second basket, "apple" is found, but prices["milk"] on line 2 asks for a key the dictionary does not have, which is a KeyError (Day 8). The bottom frame is lookup, line 2; above it is basket_total, line 8, and above that line 14. The message of a KeyError is just the missing key.
Reread 15.1 Reading a Traceback from the Bottom Up if your answer differed.
15.2 Common Exceptions
The last line of a traceback starts with the exception’s type, a name such as ValueError. Every exception has a type, and the type tells you what kind of problem happened before you read the rest of the message. A handful of types cover most beginner errors. You have met each of them on an earlier day:
>>> int("ten")
Traceback (most recent call last):
...
ValueError: invalid literal for int() with base 10: 'ten'
>>> "Score: " + 90
Traceback (most recent call last):
...
TypeError: can only concatenate str (not "int") to str
>>> prices = {"apple": 0.5}
>>> prices["pear"]
Traceback (most recent call last):
...
KeyError: 'pear'
>>> scores = [90, 85]
>>> scores[2]
Traceback (most recent call last):
...
IndexError: list index out of range
>>> open("missing_notes.txt", encoding="utf-8")
Traceback (most recent call last):
...
FileNotFoundError: [Errno 2] No such file or directory: 'missing_notes.txt'
>>> 12 / 0
Traceback (most recent call last):
...
ZeroDivisionError: division by zeroIn the REPL the traceback is shorter, because there is no file: only the last line matters. Each line asks for something impossible in a different way:
| Exception | What it means | Typical cause |
|---|---|---|
ValueError |
The type is right but this value is not usable | int("ten"): int takes strings, but not that one |
TypeError |
The operation does not work on this type at all | "Score: " + 90: text plus a number |
KeyError |
A dictionary has no such key | prices["pear"] when there is no "pear" |
IndexError |
A list or string has no such position | scores[2] in a list of 2 items (indexes 0 and 1) |
FileNotFoundError |
No file exists at that path | open("missing_notes.txt") with a misspelled name |
ZeroDivisionError |
Dividing by zero, which has no answer | 12 / 0, or total / count when count is 0 |
The difference between ValueError and TypeError trips people up, so compare them directly. int("ten") is a ValueError: int() happily accepts strings, so the type is fine, but the string "ten" has no whole-number meaning. "Score: " + 90 is a TypeError: + cannot join a string and an int no matter what the values are, so the problem is the type itself.
When you see FileNotFoundError, check the spelling of the path it names and which folder the program runs from (Day 14).
Key idea
The exception type names the kind of problem: ValueError for a bad value, TypeError for the wrong type, KeyError
and IndexError for a missing key or position, FileNotFoundError for a missing file, ZeroDivisionError for
dividing by zero.
Pause and try
Question 2 · Multiple choice
ages = {"Ana": 15, "Ben": 16}. Which line stops with a KeyError?
ages["Ana"] + 1ages["ana"]ages["Ana"] + "1"ages.get("Cy")
Check your reasoning
B. Keys are compared exactly, and "ana" with a small a is not the key "Ana", so the lookup fails with KeyError: 'ana'.
- A is wrong:
ages["Ana"]is 15, and15 + 1is 16. - C is wrong: the lookup works, but
15 + "1"adds a number and a string, which is aTypeError, not aKeyError. - D is wrong:
get()never raises aKeyError; for a missing key it gives backNone(Day 8).
Reread 15.2 Common Exceptions if you chose differently.
Question 3 · Short answer
Name the exception each line raises, and say in a few words why: (a) int("3.5"); (b) "abc"[3]; (c) len(42).
Check your reasoning
Model answer:
- (a)
ValueError:int()takes strings, but"3.5"is not a whole number. - (b)
IndexError:"abc"has indexes 0 to 2, so there is no position 3. - (c)
TypeError: an int has no length, solen()does not work on that type at all.
A correct answer separates “right type, unusable value” (a) from “wrong type” (c). Reread 15.2 Common Exceptions if your reasoning differed.
15.3 Catching an Exception with try and except
Here is a ticket program in the style of Day 5. It works well until a user types a word:
age = int(input("Age: "))
print(f"Your ticket costs {5 if age < 12 else 9} dollars.")
print("Enjoy the film!")Age: twelve
Traceback (most recent call last):
File ".../day15/ticket_crash.py", line 1, in <module>
age = int(input("Age: "))
ValueError: invalid literal for int() with base 10: 'twelve'
Checking the text with an if before calling int() is harder than it looks: there are many ways to write a number (12, -3, +7) and even more ways not to (15.9 comes back to this). What you want is to try the conversion, and if it fails, do something sensible instead of crashing.
That is the job of the try statement. Python runs the code in its try block. If an exception of the type you named happens there, Python does not stop the program; it jumps to your except block instead. Running an except block for an exception is called catching or handling it.
try:
statements that might raise an exception
except ExceptionType:
statements to run if that exception happens
try:starts the try block: the indented lines Python will attempt.except ValueError:is an except clause. It names the exception type to catch, and its indented block is the handler.- After either block finishes, the program carries on with the first line after the whole statement.
Worked example: A ticket program that survives a word
try:
age = int(input("Age: "))
print(f"Your ticket costs {5 if age < 12 else 9} dollars.")
except ValueError:
print("Please type your age as a number, such as 12.")
print("Enjoy the film!")Age: twelve
Please type your age as a number, such as 12.
Enjoy the film!Follow what Python does, step by step:
| Step | Line | What happens |
|---|---|---|
| 1 | 1 | Enter the try block |
| 2 | 2 | input returns "twelve"; int("twelve") raises a ValueError |
| 3 | 3 | Skipped: the rest of the try block is abandoned the moment the exception happens |
| 4 | 4 | The exception is a ValueError, which matches this except clause |
| 5 | 5 | The handler prints its message; the exception is now handled |
| 6 | 6 | The program carries on normally |
If the user types 12 instead, line 2 succeeds, line 3 prints Your ticket costs 9 dollars., the except clause is skipped completely, and line 6 runs. Either way the program reaches the end.
The key detail is step 3. An exception does not finish the try block; it leaves it at once. To see both paths in one run, this loop tries three strings:
for text in ["12", "twelve", "7"]:
print(f"Trying {text}")
try:
age = int(text)
print(f" Next year: {age + 1}")
except ValueError:
print(" Not a whole number")
print("All done")Trying 12
Next year: 13
Trying twelve
Not a whole number
Trying 7
Next year: 8
All done
For "twelve", line 4 raises, so line 5 never runs for it. The handler prints its message, and the loop goes on to "7" as usual. A handled exception affects only the try statement it happened in.
Key idea
try: runs its block; if a listed exception happens there, Python abandons the rest of the block and runs the
matching except handler, then carries on after the statement. If nothing goes wrong, the handler is skipped.
Watch out
Using a variable the failed line never made. When int(text) fails, the assignment to age never happens:
text = "twelve"
try:
age = int(text)
except ValueError:
print("Not a number.")
print(f"Next year you will be {age + 1}.")Not a number.
Traceback (most recent call last):
File ".../day15/skipped_assignment.py", line 6, in <module>
print(f"Next year you will be {age + 1}.")
^^^
NameError: name 'age' is not definedThe handler printed its message, the program carried on, and line 6 used age, which was never created. Catching the first error just moved the crash to the next line. Code that needs the result belongs where it only runs on success: inside the try block after the risky line (as in convert_each.py), or in an else block, which 15.5 shows. Another fix is a handler that leaves things usable, for example by setting a default value.
Pause and try
Question 4 · Tracing
What does this program print? Track total.
total = 0
for text in ["5", "five", "2.5", "10"]:
try:
total += int(text)
print(f"added {text}")
except ValueError:
print(f"skipped {text}")
print(f"total {total}")Check your reasoning
added 5
skipped five
skipped 2.5
added 10
total 15text |
int(text) |
total after |
Printed |
|---|---|---|---|
"5" |
5 | 5 | added 5 |
"five" |
raises ValueError |
5 | skipped five |
"2.5" |
raises ValueError |
5 | skipped 2.5 |
"10" |
10 | 15 | added 10 |
int("2.5") is a ValueError even though 2.5 is a number: int() only accepts whole-number text. When the conversion fails, total += ... does not happen, because the exception leaves the try block before the addition is done, so a skipped string never changes the total.
Reread 15.3 Catching an Exception with try and except if your output differed.
15.4 Catching Specific Exceptions
One try block can fail in more than one way, and each way usually needs a different message. This program shares pizza slices: the text might not be a number, or the number of people might be 0.
Worked example: One except clause per kind of problem
def share(slices_text, people_text):
try:
slices = int(slices_text)
people = int(people_text)
print(f"{slices // people} slices each")
except ValueError:
print("Type whole numbers, such as 12 and 4.")
except ZeroDivisionError:
print("Nobody to share with: people must be at least 1.")
for slices_text, people_text in [("12", "4"), ("12", "four"), ("12", "0")]:
share(slices_text, people_text)3 slices each
Type whole numbers, such as 12 and 4.
Nobody to share with: people must be at least 1.A try statement can have several except clauses. When an exception happens, Python checks them from top to bottom and runs the first one whose type matches. The others are skipped.
| Call | What happens in the try block | Handler that runs |
|---|---|---|
share("12", "4") |
Nothing goes wrong; prints 3 | None |
share("12", "four") |
Line 4 raises ValueError |
except ValueError: |
share("12", "0") |
Line 5 raises ZeroDivisionError |
except ZeroDivisionError: |
Line 12 uses tuple unpacking from Day 7: each pair in the list is split into slices_text and people_text.
When two exceptions deserve the same handling, list them in a tuple, and add as with a name to get hold of the exception itself:
def share(slices_text, people_text):
try:
slices = int(slices_text)
people = int(people_text)
print(f"{slices // people} slices each")
except (ValueError, ZeroDivisionError) as error:
print(f"Could not share: {error}")
for slices_text, people_text in [("12", "4"), ("12", "four"), ("12", "0")]:
share(slices_text, people_text)3 slices each
Could not share: invalid literal for int() with base 10: 'four'
Could not share: division by zero
except (ValueError, ZeroDivisionError) as error: catches either type and stores the exception in error. Putting error in an f-string (or calling str(error)) gives its message, the text after the colon on a traceback’s last line. For a KeyError, the message is the missing key in quotes, such as 'pear'.
What about an exception you did not list? It is not caught. It carries on exactly as if there were no try statement, and stops the program:
def share(slices_text, people_text):
try:
slices = int(slices_text)
people = int(people_text)
print(f"{slices // people} slices each")
except ValueError:
print("Type whole numbers, such as 12 and 4.")
share("12", "4")
share("12", "0")
print("Done sharing")3 slices each
Traceback (most recent call last):
File ".../day15/share_pizza_uncaught.py", line 11, in <module>
share("12", "0")
~~~~~^^^^^^^^^^^
File ".../day15/share_pizza_uncaught.py", line 5, in share
print(f"{slices // people} slices each")
~~~~~~~^^~~~~~~~
ZeroDivisionError: division by zero
This is a feature, not a gap. You catch the problems you expect and know how to answer. A problem you did not expect is probably a bug, and a bug should stop the program loudly, with a traceback that points straight at it.
Key idea
Name the exact exception types you expect: except ValueError:, or except (ValueError, ZeroDivisionError) as error:
to treat several alike and read the message. Python runs the first matching clause; any other exception still stops
the program.
Watch out
The bare except. Python lets you write except: with no type at all. It catches every exception, including the ones that mean your code is wrong. This function has a typo on line 5:
def total_cost(price_text, quantity_text):
try:
price = float(price_text)
quantity = int(quantity_text)
return price * quantiy
except:
print("Please type numbers.")
print(total_cost("2.50", "4"))Please type numbers.
NoneThe user typed perfectly good numbers, and the program blamed them. Line 5 raised a NameError for quantiy, the bare except caught it, and the real cause disappeared. The function then ended without a return, so it gave back None (Day 9). A bare except even catches Ctrl+C: Python reports Ctrl+C as an exception called KeyboardInterrupt, so a loop with a bare except inside can refuse to stop when you press it.
Name the exception you expect instead, and the typo shows itself:
def total_cost(price_text, quantity_text):
try:
price = float(price_text)
quantity = int(quantity_text)
return price * quantiy
except ValueError:
print("Please type numbers.")
print(total_cost("2.50", "4"))Traceback (most recent call last):
File ".../day15/bare_except_fixed.py", line 10, in <module>
print(total_cost("2.50", "4"))
~~~~~~~~~~^^^^^^^^^^^^^
File ".../day15/bare_except_fixed.py", line 5, in total_cost
return price * quantiy
^^^^^^^
NameError: name 'quantiy' is not defined. Did you mean: 'quantity'?Now the traceback points at the misspelled name, and fixing it to quantity makes the program print 10.0. Never write a bare except. Writing except Exception: is almost as bad: it still catches every ordinary error, typos included.
Pause and try
Question 5 · Multiple choice
What does this program print?
stock = {"pens": 12, "pads": 0}
for item in ["pens", "pads", "rulers"]:
try:
per_box = 24 // stock[item]
print(f"{item}: {per_box}")
except ZeroDivisionError:
print(f"{item}: out of stock")
except KeyError:
print(f"{item}: we do not sell these")
print("Report done")pens: 2,pads: out of stock,rulers: we do not sell these,Report donepens: 2,pads: out of stock, then aKeyErrortracebackpens: 2,pads: 0,rulers: we do not sell these,Report donepens: 2, then aZeroDivisionErrortraceback
Check your reasoning
A.
pens: 2
pads: out of stock
rulers: we do not sell these
Report done24 // 12 is 2. For "pads", the stock is 0, so 24 // 0 raises ZeroDivisionError, which the first clause catches. For "rulers", stock["rulers"] raises KeyError before any division happens; Python checks the clauses top to bottom, ZeroDivisionError does not match, KeyError does.
- B is wrong: the second except clause catches the
KeyError. - C is wrong:
24 // 0does not give 0; it raises an exception, so line 6 is skipped. - D is wrong: the
ZeroDivisionErroris caught by the first clause, so the loop carries on.
Reread 15.4 Catching Specific Exceptions if you chose differently.
15.5 else and finally
15.3 left a question open: where should the code go that uses a successful result? You could put it all in the try block, but then every line there is “protected”, and an unexpected ValueError from some later line would be caught by a handler written for the conversion. Good try blocks are small: only the line that can fail in the way you expect. A try statement has two more optional clauses for the rest.
- An else clause runs only if the try block finished without an exception. Put the code that uses the result there.
- A finally clause runs always, whether the try block succeeded, failed and was handled, or failed and was not handled. Put cleanup there: anything that must happen no matter what.
The full shape, in the only order Python accepts:
try:
risky line
except SomeError:
runs if SomeError happened
else:
runs if nothing went wrong
finally:
runs every time
Worked example: Every path through a try statement
for text in ["88", "eighty"]:
print(f"Reading {text}")
try:
score = int(text)
except ValueError:
print(" except: not a number")
else:
print(f" else: doubled is {score * 2}")
finally:
print(" finally: checked")Reading 88
else: doubled is 176
finally: checked
Reading eighty
except: not a number
finally: checkedtext |
try block | except | else | finally |
|---|---|---|---|---|
"88" |
succeeds, score is 88 |
skipped | runs | runs |
"eighty" |
raises ValueError |
runs | skipped | runs |
except and else are opposites: exactly one of them runs (when the exception is one you listed). finally runs in both rows. The else block also fixes the Watch Out in 15.3: score is used only where it is certain to exist.
finally runs even when the exception is not caught. Here there is no except clause at all, which is allowed:
try:
print("Saving...")
share = 10 / 0
finally:
print("Cleanup runs anyway")
print("Never printed")Saving...
Cleanup runs anyway
Traceback (most recent call last):
File ".../day15/finally_uncaught.py", line 3, in <module>
share = 10 / 0
~~~^~~
ZeroDivisionError: division by zero
The division fails, Python runs the finally block on its way out, and then the program stops with the traceback. Never printed never prints. finally also runs when the try block is left by return or break. Here the function has already worked out its return value, but finally prints before the value is handed back:
def first_letter(word):
try:
return word[0]
finally:
print(f"checked {word}")
print(first_letter("kiwi"))checked kiwi
k
On Day 14, with open(...) closed the file for you even if something went wrong inside the with block. Now you can see what that means: with does the same job as a finally that closes the file, built in.
Key idea
Keep the try block to the risky line. else runs when the try block raised nothing, so it is where the result gets
used; finally runs on every path, even through an uncaught exception or a return.
Watch out
Clauses in the wrong order. The order is fixed: try, then every except, then else, then finally. Anything else is a syntax error, so nothing runs at all:
try:
count = int("7")
finally:
print("Finished")
except ValueError:
print("Not a number") File ".../day15/wrong_order.py", line 5
except ValueError:
^^^^^^
SyntaxError: invalid syntaxOnce finally has appeared, the try statement is over, so Python sees an except with nothing to belong to. Move the finally block to the end. It also helps to remember what else means here. It is not the else of an if: it runs when the try block succeeded, not when something failed.
Pause and try
Question 6 · Tracing
What does this program print?
line = ""
for text in ["4", "x", "0"]:
try:
number = int(text)
result = 12 // number
except ValueError:
line += "V"
except ZeroDivisionError:
line += "Z"
else:
line += str(result)
finally:
line += "|"
print(line)Check your reasoning
3|V|Z|text |
What happens | Added to line |
|---|---|---|
"4" |
12 // 4 is 3; no exception, so else runs |
3, then a bar |
"x" |
int("x") raises ValueError |
V, then a bar |
"0" |
int("0") works, 12 // 0 raises ZeroDivisionError |
Z, then a bar |
finally adds a bar on every pass, after whichever of except or else ran. str(result) turns the int 3 into text so it can be added to the string.
Reread 15.5 else and finally if your output differed.
15.6 Exceptions Travel Up Through Functions
In 15.1 the traceback listed every function call that was in progress. Those same calls decide where an exception can be caught. When a function raises an exception and does not handle it, the function stops at once (no return happens), and the exception arrives at the line that called the function, as if that line had raised it. If that line is in a try block with a matching except clause, the exception is caught there. If not, it carries on up to the next caller, and so on. Passing up through the calls like this is called propagating. Only if it reaches the main part of the file with nothing to catch it does the program stop with a traceback.
Worked example: Caught two calls up
def parse_quantity(text):
print(f" parsing {text}")
quantity = int(text)
print(" parsed")
return quantity
def place_order(text):
quantity = parse_quantity(text)
print(f" ordered {quantity}")
for text in ["3", "lots"]:
print(f"Order: {text}")
try:
place_order(text)
except ValueError:
print(" cancelled: bad quantity")
print("Shop still open")Order: 3
parsing 3
parsed
ordered 3
Order: lots
parsing lots
cancelled: bad quantity
Shop still openFor "lots", follow the exception up:
parse_quantityprintsparsing lots, thenint("lots")on line 3 raises aValueError.parse_quantityhas no try statement, so it stops: line 4 (parsed) and thereturnnever run.- The exception arrives at line 9 in
place_order, which calledparse_quantity. No try there either, soplace_orderstops too:orderednever prints. - The exception arrives at line 16, which called
place_order. That line is inside a try block, and the except clause matches, so the handler printscancelled: bad quantity. - The loop carries on, and the program ends normally with
Shop still open.
This means you can catch an error far from where it happens, and usually you should. parse_quantity knows how to convert text, but it does not know what the program should do with a bad quantity: ask again? Skip the order? Show a message in a window? The code near the user knows. A good rule: handle an exception at the place that can do something useful about it. Low-level functions let it propagate, and the code that talks to the user catches it.
Key idea
An exception that a function does not handle stops that function and propagates to the line that called it, then to
that line’s caller, until a matching except catches it. Catch it where the program can respond sensibly.
Pause and try
Question 7 · Tracing
What does this program print? Watch the finally in outer.
def inner(n):
print("inner start")
result = 10 // n
print("inner end")
return result
def outer(n):
try:
print("outer start")
value = inner(n)
print("outer got", value)
finally:
print("outer finally")
try:
outer(0)
except ZeroDivisionError:
print("caught at the top")Check your reasoning
outer start
inner start
outer finally
caught at the topouter(0) prints outer start and calls inner(0). inner prints inner start, then 10 // 0 raises ZeroDivisionError. inner has no handler, so it stops (inner end never prints) and the exception propagates into outer, at line 11. outer’s try statement has no except clause, so outer got is skipped, but its finally runs on the way out: outer finally. The exception keeps propagating to line 18, where the except clause catches it.
Reread 15.6 Exceptions Travel Up Through Functions and 15.5 else and finally if your output differed.
15.7 Raising Your Own Errors with raise
So far Python has raised every exception. But your own functions also meet values they cannot use. A function that reads a rating from 1 to 5 gets "9". int("9") is happy, so Python raises nothing, but 9 is still wrong. What should the function do?
It could print a message, but then it decides how the problem is shown, which is the caller’s job (15.6). It could return None, but a caller that forgets to check will carry on with None and crash somewhere later, far from the cause. Python’s answer is that the function raises an exception itself, exactly like int() does. The raise statement creates an exception and raises it:
raise ValueError("a message that explains the problem")
raiseis a keyword.ValueError(...)creates a new exception of that type. The text in the parentheses becomes its message.- From that moment, everything works as in 15.6: the function stops, and the exception propagates to the caller.
Worked example: A rating that must be 1 to 5
def parse_rating(text):
rating = int(text)
if not 1 <= rating <= 5:
raise ValueError(f"rating must be 1 to 5, not {rating}")
return rating
for text in ["4", "9", "great"]:
try:
rating = parse_rating(text)
except ValueError as error:
print(f"Skipped {text}: {error}")
else:
print(f"Saved rating {rating}")Saved rating 4
Skipped 9: rating must be 1 to 5, not 9
Skipped great: invalid literal for int() with base 10: 'great'"4":intgives 4, the check on line 3 passes, line 5 returns 4, and theelseclause saves it."9":intgives 9, butnot 1 <= 9 <= 5isTrue(the Day 4 chained comparison), so line 4 raises aValueErrorwith a message built by an f-string. Thereturnon line 5 never runs."great":int("great")raises its ownValueErroron line 2.
The caller handles both with one except clause, because both are “a value that cannot be a rating”. ValueError is the right type to raise for exactly that: the type was fine (a string, then an int), but the value was not usable. Raise a TypeError instead when a function gets the wrong type altogether.
If nobody catches a raised exception, it ends the program with a traceback like any other, and the message is the one you wrote:
def parse_rating(text):
rating = int(text)
if not 1 <= rating <= 5:
raise ValueError(f"rating must be 1 to 5, not {rating}")
return rating
print(parse_rating("4"))
print(parse_rating("9"))
print("Not reached")4
Traceback (most recent call last):
File ".../day15/rating_uncaught.py", line 9, in <module>
print(parse_rating("9"))
~~~~~~~~~~~~^^^^^
File ".../day15/rating_uncaught.py", line 4, in parse_rating
raise ValueError(f"rating must be 1 to 5, not {rating}")
ValueError: rating must be 1 to 5, not 9
That is why a good message says what was expected and what arrived. Whoever reads this traceback knows what went wrong without opening the code.
Key idea
raise ValueError("message") stops your function and hands the problem to its callers, exactly as Python’s own errors
do. Raise when your function gets input it cannot use, and let the caller decide what to do about it.
Watch out
return instead of raise. Writing return ValueError(...) creates the exception but does not raise it. It is returned like any other value:
def parse_rating(text):
rating = int(text)
if not 1 <= rating <= 5:
return ValueError(f"rating must be 1 to 5, not {rating}")
return rating
for text in ["4", "9"]:
try:
rating = parse_rating(text)
except ValueError as error:
print(f"Skipped {text}: {error}")
else:
print(f"Saved rating {rating}")Saved rating 4
Saved rating rating must be 1 to 5, not 9No exception happened, so the except clause was skipped and the else clause “saved” an exception object as if it were a rating. The f-string showed its message. Only raise sets off the jump to a handler.
Pause and try
Question 8 · Short answer
A classmate’s function checks a percentage and, when it is above 100, does print("Too big!") and then return None. Give two reasons why raising a ValueError is better.
Check your reasoning
Model answer:
- The caller decides how to respond. A function that prints has already chosen the message and where it appears. With
raise, the caller can ask again, skip the value, or show its own message. - The problem cannot be silently ignored. A caller that forgets to check for
Nonecarries on and crashes later, far from the cause, or gives wrong results. An uncaughtValueErrorstops the program right away with a traceback whose message says what went wrong.
A correct answer mentions that the handling moves to the caller and that an exception, unlike a returned None, cannot be overlooked by accident.
Reread 15.7 Raising Your Own Errors with raise if your reasoning differed.
15.8 Defining a Custom Exception
In the to-do manager, a user can type two (not a number) or 7 (a number, but no such task). Both could be ValueError, but then the handler cannot tell them apart and cannot give a precise message. Worse, a ValueError from some other line, maybe a real bug, would be caught by the same handler.
You can make your own exception type with a name that says exactly what went wrong. A custom exception takes two lines. Copy this pattern and change only the name:
class TaskNumberError(Exception):
pass
Tomorrow, Day 16, explains class properly. For today, here is what each piece does:
class TaskNumberErrorcreates a new type namedTaskNumberError. By convention, the names of exception types end inErrorand are written in CapWords (each word starts with a capital letter, no underscores), like the built-in ones.(Exception)makes the new type a kind of exception, so it can be raised and caught. Leave it out and Python refuses to raise it.passis a statement that does nothing. A block cannot be empty, and this one has nothing to say, sopassfills it.
Put the two lines near the top of the file, below the imports, so the type exists before any code raises or catches it. After that, TaskNumberError works like ValueError: you raise it with a message and catch it by name.
Worked example: Telling two problems apart
class TaskNumberError(Exception):
pass
def check_task_number(tasks, number):
if not 1 <= number <= len(tasks):
raise TaskNumberError(f"there is no task {number}; choose 1 to {len(tasks)}")
tasks = [{"title": "Buy milk", "done": False}, {"title": "Walk dog", "done": True}]
for typed in ["2", "7", "two"]:
try:
number = int(typed)
check_task_number(tasks, number)
except ValueError:
print(f"{typed}: please type a number")
except TaskNumberError as error:
print(f"{typed}: {error}")
else:
print(f"{typed}: {tasks[number - 1]['title']}")2: Walk dog
7: there is no task 7; choose 1 to 2
two: please type a numbertyped |
What happens | Clause that runs |
|---|---|---|
"2" |
int gives 2; the check passes |
else: prints task 2’s title |
"7" |
int gives 7; line 7 raises TaskNumberError |
except TaskNumberError |
"two" |
int("two") raises ValueError before any check |
except ValueError |
Each kind of problem has its own type, so each gets its own handler and message, and a ValueError can never be mistaken for a bad task number.
Uncaught, your exception produces a normal traceback, ending with your type’s name and message:
class TaskNumberError(Exception):
pass
def check_task_number(tasks, number):
if not 1 <= number <= len(tasks):
raise TaskNumberError(f"there is no task {number}; choose 1 to {len(tasks)}")
tasks = [{"title": "Buy milk", "done": False}]
check_task_number(tasks, 1)
print("Task 1 is fine")
check_task_number(tasks, 3)Task 1 is fine
Traceback (most recent call last):
File ".../day15/task_number_uncaught.py", line 13, in <module>
check_task_number(tasks, 3)
~~~~~~~~~~~~~~~~~^^^^^^^^^^
File ".../day15/task_number_uncaught.py", line 7, in check_task_number
raise TaskNumberError(f"there is no task {number}; choose 1 to {len(tasks)}")
TaskNumberError: there is no task 3; choose 1 to 1
Key idea
class SomethingError(Exception): followed by an indented pass defines a custom exception type. Raise and catch it
by name, so one specific problem gets its own handler and is never confused with a built-in error.
Watch out
Leaving out (Exception). Without it, the new type is not an exception:
class TaskNumberError:
pass
raise TaskNumberError("there is no task 3")Traceback (most recent call last):
File ".../day15/no_exception_base.py", line 5, in <module>
raise TaskNumberError("there is no task 3")
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
TypeError: TaskNumberError() takes no argumentsThe message is confusing because Python fails while creating the value, before it even tries to raise it: a plain class does not accept a message. Even without the message, raise would refuse it with a TypeError saying exceptions must derive from BaseException. Copy the pattern exactly, with (Exception).
Pause and try
Question 9 · Tracing
What does this program print? Each seat goes through two functions before any handler.
class SeatError(Exception):
pass
def check_seat(row, seat):
if seat > 10:
raise SeatError(f"row {row} has only 10 seats")
return f"{row}{seat}"
def book(row, seat_text):
seat = int(seat_text)
label = check_seat(row, seat)
print(f"booked {label}")
for row, seat_text in [("A", "4"), ("B", "12"), ("C", "two")]:
try:
book(row, seat_text)
except SeatError as error:
print(f"sorry, {error}")
except ValueError:
print("seat must be a number")
print("done")Check your reasoning
booked A4
sorry, row B has only 10 seats
seat must be a number
done("A", "4"):intgives 4,check_seatreturns"A4", andbookprints it.("B", "12"): 12 is over 10, socheck_seatraisesSeatError. It propagates throughbook(which stops before printing) to the loop, whereexcept SeatErrorcatches it and prints its message.("C", "two"):int("two")raisesValueErrorinbook, beforecheck_seatis ever called; the second clause catches it.
Reread 15.8 Defining a Custom Exception and 15.6 Exceptions Travel Up Through Functions if your output differed.
15.9 Ask Forgiveness, Not Permission
You now have two ways to deal with something that might fail. You can check first, and only act if the check passes. Or you can just act, and handle the exception if it fails. Both appear in Python code, and they have names:
- LBYL, “look before you leap”: test the conditions with
if, then act. - EAFP, “easier to ask forgiveness than permission”: act inside a
try, and handle the failure inexcept.
Worked example: The same lookup, two styles
stock = {"pens": 12, "pads": 5}
# Look before you leap: check first, then act.
item = "rulers"
if item in stock:
print(f"{item}: {stock[item]}")
else:
print(f"{item}: not stocked")
# Easier to ask forgiveness than permission: act, and handle the failure.
try:
print(f"{item}: {stock[item]}")
except KeyError:
print(f"{item}: not stocked")rulers: not stocked
rulers: not stockedBoth halves give the same result. The LBYL half asks item in stock first (Day 8). The EAFP half looks the item up and catches the KeyError if it is missing.
For a dictionary, both styles are fine. But in other places, checking first is harder than it looks. Think about checking text before int(). These all work:
>>> int("-7")
-7
>>> int(" 42 ")
42
>>> int("+3")
3
>>> int("4.0")
Traceback (most recent call last):
...
ValueError: invalid literal for int() with base 10: '4.0'A minus sign, spaces around the number, and a plus sign are all accepted, but "4.0" is not. An if that tried to copy int()’s rules would be long and still miss a case. The Day 12 to-do manager stripped the spaces off and then checked all(ch in "0123456789" for ch in text): two steps to copy part of int()’s rules, and it still refuses +3, which int() accepts. EAFP asks the expert: let int() decide, and catch the ValueError.
Files make the strongest case for EAFP. On Day 14 the to-do manager checked SAVE_FILE.exists() before loading. But a file can be deleted between the check and the open, and a file that exists can still be unreadable, or not valid JSON. The check answers one question once; it cannot promise that the next line will work. Trying to open the file and catching FileNotFoundError covers the real moment of opening.
| Style | Looks like | Good for |
|---|---|---|
| LBYL | if key in prices: then prices[key] |
Simple, cheap, complete checks; the “failure” is a normal case (get() too) |
| EAFP | try: int(text) / except ValueError: |
Checks that are hard to write exactly, and anything outside your program, such as files |
Python programmers lean toward EAFP, because it puts the normal path first and lets the code that does the work decide what counts as failure. Neither is wrong; choose the one whose check is honest and complete.
Key idea
LBYL checks with if before acting; EAFP acts in a try and handles the exception. Prefer EAFP when a correct check
would be complicated or could be out of date by the time you act, as with converting text and opening files.
Pause and try
Question 10 · Short answer
A program loads settings.json. Version 1 does if path.exists(): and then opens and reads the file with json.load. Version 2 opens and reads it in a try with except FileNotFoundError: and except json.JSONDecodeError:. Give two situations that crash version 1 but not version 2.
Check your reasoning
Model answer:
- The file is deleted (or renamed) after the
exists()check but before the open, so the open raisesFileNotFoundError, which version 1 does not catch. - The file exists but is not valid JSON, for example half-written when the computer lost power, so
json.loadraisesJSONDecodeError.exists()only says the file is there, not that its contents are usable.
A correct answer points out that the check in version 1 does not cover everything that can go wrong at the moment of reading, while version 2 handles the failures where they happen.
Reread 15.9 Ask Forgiveness, Not Permission if your reasoning differed.
Putting it together
A teacher keeps scores in a text file, one name,score per line. Some lines have mistakes. The program reports the average of the good lines and explains each bad one, and it copes when the file is missing.
class_scores.txt:
Ana,88
Ben,ninety
Cy
Dee,105
Eli,72
from pathlib import Path
class ScoreRangeError(Exception):
pass
def read_lines(path):
with open(path, encoding="utf-8") as file:
return [line.strip() for line in file]
def parse_line(line):
name, score_text = line.split(",")
score = int(score_text)
if not 0 <= score <= 100:
raise ScoreRangeError(f"{score} is outside 0 to 100")
return name, score
def read_scores(path):
try:
lines = read_lines(path)
except FileNotFoundError:
print(f"No file named {path}: nothing to report.")
return []
scores = []
for number, line in enumerate(lines, 1):
try:
name, score = parse_line(line)
except (ValueError, ScoreRangeError) as error:
print(f"Line {number} skipped ({line}): {error}")
else:
scores.append(score)
return scores
for path in [Path("class_scores.txt"), Path("missing_scores.txt")]:
scores = read_scores(path)
if scores:
print(f"{len(scores)} scores, average {sum(scores) / len(scores):.1f}")Line 2 skipped (Ben,ninety): invalid literal for int() with base 10: 'ninety'
Line 3 skipped (Cy): not enough values to unpack (expected 2, got 1)
Line 4 skipped (Dee,105): 105 is outside 0 to 100
2 scores, average 80.0
No file named missing_scores.txt: nothing to report.
| Line | Inside parse_line |
Caught by read_scores as |
Idea |
|---|---|---|---|
Ana,88 |
Splits, converts, passes the range check | else: score kept |
15.5 |
Ben,ninety |
int("ninety") raises ValueError |
ValueError |
15.3, 15.4 |
Cy |
split(",") gives one item; unpacking it into two names raises ValueError |
ValueError |
15.2 |
Dee,105 |
Line 17 raises ScoreRangeError |
ScoreRangeError |
15.7, 15.8 |
Eli,72 |
All checks pass | else: score kept |
15.5 |
read_linesdoes not handle anything. Whenmissing_scores.txtdoes not exist, itsopenraisesFileNotFoundError, which propagates (15.6) toread_scores, where the except clause prints a message and returns an empty list. The finalif scores:then skips the average, so there is noZeroDivisionErroreither.- The try block around
read_linesholds one line, and the try block in the loop holds one line. Each catches only the exceptions it expects, named exactly (15.4), and none is bare. - The unpacking line shows that you do not need to predict every way a line can be broken:
int()and unpacking raiseValueErrorfor you, the program checks only what Python cannot (the range), and one handler covers them all (EAFP, 15.9). - The average is (88 + 72) / 2 = 80.0.
Vocabulary
| Term | Definition | Example |
|---|---|---|
| exception | A value Python creates to describe a problem while a program runs | ValueError("...") |
| raise (an exception) | Signal a problem: stop normal work and start looking for a handler | int("ten") raises one |
| traceback | The report of the calls in progress when an uncaught exception stopped the program | Traceback (most recent call last): |
| frame | One call in a traceback: where it was and the line running | line 2, in average |
| exception type | The kind of exception, named on the traceback’s last line | KeyError |
| message | The text after the type, explaining the problem | division by zero |
| try statement / try block | Code Python attempts, with handlers ready if it fails | try: |
| except clause / handler | Names an exception type to catch / the block that runs when it is caught | except ValueError: |
| catch / handle | Run a handler for an exception, so the program carries on | the ticket program |
bare except |
An except clause with no type; catches everything, so never use it | except: |
| else clause (try) | Runs only if the try block raised nothing | else: print(score * 2) |
| finally clause | Runs on every path out of the try statement | finally: print("checked") |
| propagate | An unhandled exception passing up to the caller, then its caller | from parse_quantity up to the loop |
| raise statement | Raises an exception from your own code | raise ValueError("too big") |
| custom exception | Your own exception type, made with class Name(Exception): |
TaskNumberError |
pass |
A statement that does nothing, for a block with nothing to say | pass |
| LBYL | “Look before you leap”: check with if, then act |
if key in prices: |
| EAFP | “Easier to ask forgiveness than permission”: try, then handle the failure | try: prices[key] |
Summary
- An uncaught exception stops the program with a traceback. Read it from the bottom up: the last line gives the type and message, the bottom frame shows where it happened, and the frames above show which calls led there; the cause may be in any of them.
- The common types each name a kind of problem:
ValueError,TypeError,KeyError,IndexError,FileNotFoundError, andZeroDivisionError. - A try statement runs its block, jumps to the first matching except handler if a listed exception happens, runs else if nothing did, and runs finally every time. Catch only the types you expect, never with a bare
except. - An unhandled exception propagates from a function to its caller, so you can catch it where the program can respond. Your functions can raise exceptions too, including custom types made with
class Name(Exception): pass. - EAFP (try, then handle) is often clearer and safer than LBYL (check first), especially for converting text and opening files.
Practice
Do these in your day15 folder. Type each program yourself, run it, and only then open the answer.
Question 11 · Short answer
Ask until valid. Write a function read_int(prompt) that keeps asking with input(prompt) until the user types a whole number, then returns it as an int. After each bad answer, print a short message. Use it to ask for an age and a number of tickets.
Check your reasoning
One possible solution:
def read_int(prompt):
"""Ask until the user types a whole number, then return it as an int."""
while True:
text = input(prompt)
try:
return int(text)
except ValueError:
print("Please type a whole number, such as 12.")
age = read_int("Age: ")
tickets = read_int("Tickets: ")
print(f"{tickets} tickets for age {age}")This run types twelve, 12.5, and 12 for the age, then just presses Enter, then types 3 for the tickets:
Age: twelve
Please type a whole number, such as 12.
Age: 12.5
Please type a whole number, such as 12.
Age: 12
Tickets:
Please type a whole number, such as 12.
Tickets: 3
3 tickets for age 12while True: repeats until something leaves the loop (Day 5). Here that is return int(text): when the conversion works, the function returns at once, which ends both the loop and the function. When it fails, the ValueError skips the return, the handler prints, and the loop asks again. Pressing Enter gives "", and int("") is a ValueError too. The try block holds only the conversion, and the handler names ValueError, so a typo elsewhere would still show up as an error.
Reread 15.3 Catching an Exception with try and except if your result differed.
Question 12 · Short answer
A save file that can go wrong. On Day 14 the to-do manager saved its tasks to tasks.json and loaded them at the start, checking SAVE_FILE.exists() first. Change load_tasks(path) so that a missing file means “no tasks yet” and a corrupted file (one that is not valid JSON) prints a warning and starts with no tasks, then let main call it without the exists() check. While you are there, make the task-number prompt use try and int().
Check your reasoning
First see what a corrupted file does to Day 14’s version. This corrupted.json was cut off in the middle of a save:
[
{
"title": "Return library books",
"done": trimport json
with open("corrupted.json", encoding="utf-8") as file:
tasks = json.load(file)
print(tasks)Traceback (most recent call last):
File ".../day15/todo_app/crash_load.py", line 4, in <module>
tasks = json.load(file)
File ".../json/__init__.py", line 298, in load
...
File ".../json/decoder.py", line 363, in raw_decode
raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 4 column 13 (char 55)(The frames inside the json module are shortened here; their files and line numbers depend on your Python version.) The bottom frames are inside Python’s own json module, which is not where to fix anything. Reading up, the first frame in your file is line 4, the json.load call. The exception type is JSONDecodeError; the json module also makes it available by the shorter name json.JSONDecodeError, which is what you write in an except clause.
Here is the new load_tasks, with the rest of the module unchanged from Day 14:
"""Task functions for the to-do manager. main.py imports them."""
import json
def show_menu():
"""Print the menu of choices."""
print()
print("1) Add 2) List 3) Complete 4) Delete 5) Quit")
def add_task(tasks, title):
"""Add a new, unfinished task to the end of tasks."""
tasks.append({"title": title, "done": False})
def list_tasks(tasks):
"""Print every task with its number and a check box."""
if not tasks:
print("No tasks yet.")
return
for number, task in enumerate(tasks, 1):
mark = "x" if task["done"] else " "
title = task["title"]
print(f"{number}. [{mark}] {title}")
def complete_task(tasks, number):
"""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["done"] = True
return task
def delete_task(tasks, number):
"""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, path):
"""Write tasks to the JSON file at path, replacing what was there."""
with path.open("w", encoding="utf-8") as file:
json.dump(tasks, file, indent=2)
def load_tasks(path):
"""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:
return json.load(file)
except FileNotFoundError:
return []
except json.JSONDecodeError:
print(f"Warning: {path.name} is not valid JSON, so no tasks loaded.")
return []
if __name__ == "__main__":
test_tasks = []
add_task(test_tasks, "Test the module")
print(complete_task(test_tasks, 1))
print(complete_task(test_tasks, 2))
list_tasks(test_tasks)And main.py, with load_tasks called directly and the task number read with try:
"""The to-do manager's menu loop. Run this file to start the program."""
from pathlib import Path
from tasks import (
add_task,
complete_task,
delete_task,
list_tasks,
load_tasks,
save_tasks,
show_menu,
)
SAVE_FILE = Path(__file__).parent / "tasks.json"
def main():
"""Run the menu loop until the user chooses Quit."""
tasks = load_tasks(SAVE_FILE)
print("To-do list manager")
print(f"Loaded {len(tasks)} tasks from {SAVE_FILE.name}")
while True:
show_menu()
choice = input("Choose 1-5: ").strip()
if choice == "1":
title = input("Task title: ").strip()
if title:
add_task(tasks, title)
save_tasks(tasks, SAVE_FILE)
print(f"Added: {title}")
else:
print("A task needs a title.")
elif choice == "2":
list_tasks(tasks)
elif choice in ("3", "4"):
try:
number = int(input("Task number: "))
except ValueError:
print("Please type a task number, such as 1.")
continue
if choice == "3":
task = complete_task(tasks, number)
action = "Completed"
else:
task = delete_task(tasks, number)
action = "Deleted"
if task is None:
print(f"There is no task {number}.")
else:
save_tasks(tasks, SAVE_FILE)
title = task["title"]
print(f"{action}: {title}")
elif choice == "5":
print("Goodbye!")
break
else:
print("Please choose a number from 1 to 5.")
if __name__ == "__main__":
main()A run that lists, types two as a task number, completes task 2, and asks for task 9:
To-do list manager
Loaded 2 tasks from tasks.json
1) Add 2) List 3) Complete 4) Delete 5) Quit
Choose 1-5: 2
1. [x] Return library books
2. [ ] Practice piano
1) Add 2) List 3) Complete 4) Delete 5) Quit
Choose 1-5: 3
Task number: two
Please type a task number, such as 1.
1) Add 2) List 3) Complete 4) Delete 5) Quit
Choose 1-5: 3
Task number: 2
Completed: Practice piano
1) Add 2) List 3) Complete 4) Delete 5) Quit
Choose 1-5: 4
Task number: 9
There is no task 9.
1) Add 2) List 3) Complete 4) Delete 5) Quit
Choose 1-5: 5
Goodbye!To test the two failure cases without breaking your real save file, load other paths:
from pathlib import Path
from tasks import list_tasks, load_tasks
for name in ["no_such_file.json", "corrupted.json", "tasks.json"]:
print(f"Loading {name}:")
list_tasks(load_tasks(Path(name)))Loading no_such_file.json:
No tasks yet.
Loading corrupted.json:
Warning: corrupted.json is not valid JSON, so no tasks loaded.
No tasks yet.
Loading tasks.json:
1. [x] Return library books
2. [ ] Practice pianoHow it works: the with line and json.load are the risky lines, so they are the whole try block. A missing file is normal on the very first run, so its handler returns [] quietly. A corrupted file is not normal, so its handler warns the user before returning []. This is EAFP (15.9): no exists() check that could be out of date. Notice one cost: the next save overwrites the corrupted file, so whatever was in it is lost; the warning at least tells the user. In main, int() now decides what counts as a number (it ignores spaces around the digits by itself, so no strip() is needed), and continue goes back to the menu after a bad answer.
Reread 15.4 Catching Specific Exceptions and 15.9 Ask Forgiveness, Not Permission if your result differed.
Question 13 · Short answer
Raise and handle. Write a function parse_price(text) that turns text such as "2.50" into a float, and raises ValueError with a clear message if the price is 0 or less. Then write a caller that adds up a list of price strings, skipping (with a message) any that are not usable.
Check your reasoning
One possible solution:
def parse_price(text):
"""Turn text like "2.50" into a float, or raise ValueError if it is not a usable price."""
price = float(text)
if price <= 0:
raise ValueError(f"a price must be more than 0, not {text}")
return price
def total_prices(texts):
total = 0
for text in texts:
try:
price = parse_price(text)
except ValueError as error:
print(f"Skipped: {error}")
else:
total += price
return total
print(f"Total: {total_prices(['2.50', 'free', '-1', '4.25']):.2f}")Skipped: could not convert string to float: 'free'
Skipped: a price must be more than 0, not -1
Total: 6.75parse_price lets float("free") raise its own ValueError and raises one itself for -1, so the caller sees one type of exception for “not a usable price”. total_prices catches it with as error and prints the message, and its else clause adds only prices that parsed. 2.50 + 4.25 = 6.75. The function never prints; the caller decides what to do with bad input.
Reread 15.7 Raising Your Own Errors with raise if your result differed.
Review questions
Question 14 · Tracing
What does this program print? finally and return are both in play.
def safe_divide(a, b):
try:
return a / b
except ZeroDivisionError:
return None
finally:
print(f"checked {a} / {b}")
print(safe_divide(6, 3))
print(safe_divide(1, 0))Check your reasoning
checked 6 / 3
2.0
checked 1 / 0
NoneFor safe_divide(6, 3), the try block works out 2.0 and starts to return it, but finally runs first, so checked 6 / 3 prints before line 10 prints the returned 2.0. For safe_divide(1, 0), the division raises, the handler returns None, and again finally prints first.
Reread 15.5 else and finally if your output differed.
Question 15 · Debugging
This program should print no price for any item missing from the dictionary. It prints bread: 2.25 and then crashes. Explain the bug and fix it.
prices = {"apple": 0.5, "bread": 2.25}
for item in ["bread", "milk"]:
try:
print(f"{item}: {prices[item]:.2f}")
except ValueError:
print(f"{item}: no price")Check your reasoning
The traceback ends with:
KeyError: 'milk'Looking up a missing key raises a KeyError, not a ValueError, so the except clause does not match and the exception stops the program. Catch the type that actually happens:
prices = {"apple": 0.5, "bread": 2.25}
for item in ["bread", "milk"]:
try:
print(f"{item}: {prices[item]:.2f}")
except KeyError:
print(f"{item}: no price")bread: 2.25
milk: no priceThe traceback’s last line tells you which type to catch. (Since in and get() work for dictionaries, an LBYL version would be fine here too.)
Reread 15.2 Common Exceptions and 15.4 Catching Specific Exceptions if you missed it.
Question 16 · Tracing
What does this program print?
def check_quantity(text):
quantity = int(text)
if quantity < 1:
raise ValueError("must order at least 1")
return quantity
def order(text):
try:
quantity = check_quantity(text)
except ValueError as error:
print(f"{text}: {error}")
else:
print(f"{text}: ordered {quantity}")
finally:
print("--")
for text in ["2", "0", "x"]:
order(text)Check your reasoning
2: ordered 2
--
0: must order at least 1
--
x: invalid literal for int() with base 10: 'x'
--"2" passes both checks, so else prints. For "0", int works but line 4 raises the function’s own ValueError, which propagates to order and is caught; error holds the message from line 4. For "x", int("x") raises first, with Python’s message. In every case finally prints -- last.
Reread 15.7 Raising Your Own Errors with raise if your output differed.
Question 17 · Multiple choice
What is the third line this program prints?
scores = {"Ana": [90, 85], "Ben": [70]}
for name, index in [("Ana", 1), ("Ben", 1), ("Cy", 0)]:
try:
print(scores[name][index])
except (KeyError, IndexError) as error:
print(f"problem: {error}")problem: list index out of rangeproblem: 'Cy'problem: Cy- A
KeyErrortraceback
Check your reasoning
B. The output is:
85
problem: list index out of range
problem: 'Cy'scores["Cy"] raises a KeyError, which the tuple (KeyError, IndexError) catches. A KeyError’s message is the missing key with quotes, so the f-string shows 'Cy'.
- A is the second line, from
scores["Ben"][1]: Ben’s list has only index 0. - C is wrong: a
KeyError’s message keeps the quotes around a string key. - D is wrong:
KeyErroris in the tuple, so it is caught.
Reread 15.4 Catching Specific Exceptions if you chose differently.
Question 18 · Short answer
In the corrupted-file traceback from Practice question 12, the bottom frames are in Python’s json module, in files you did not write. Explain how to read such a traceback to decide what to change in your own program.
Check your reasoning
Model answer: start at the last line for what went wrong: a JSONDecodeError, meaning the file’s contents are not valid JSON. Then walk up the frames until you reach one in your own file. That frame, crash_load.py line 4 with json.load(file), is where your code handed bad data to the library, and it is where you can act: wrap that call in a try and catch json.JSONDecodeError. Frames inside Python’s own modules show how the library noticed the problem; they are almost never where the fix goes.
A correct answer reads the last line for the type, finds the lowest frame in your own code, and fixes or handles the problem there rather than in the library.
Reread 15.1 Reading a Traceback from the Bottom Up if your reasoning differed.
Question 19 · Short answer
Day 12’s to-do manager checked a task number with all(ch in "0123456789" for ch in text) before int(text), then checked 1 <= number <= len(tasks). Which of these two checks would you replace with try and except ValueError, which would you keep as an if (or move into a function that raises TaskNumberError), and why?
Check your reasoning
Model answer: replace the digits check with EAFP. It is a partial copy of rules int() already has (it refuses +3, which int() accepts), and getting such a check exactly right is hard, while int() already knows the rules and raises ValueError when they are broken. Keep the range check: 1 <= number <= len(tasks) is simple, complete, and nothing in Python would raise for it, because tasks[number - 1] with number 0 gives tasks[-1], the last task, instead of an error. Moving that check into a function that raises TaskNumberError lets the menu loop handle “not a number” and “no such task” with two separate except clauses.
A correct answer says that EAFP suits the conversion because the real rules live in int(), and that the range must be checked by the program because no exception would report it reliably.
Reread 15.9 Ask Forgiveness, Not Permission and 15.8 Defining a Custom Exception if your reasoning differed.
Go further
The official Python Tutorial covers today’s topics, with a few more advanced features such as exception chaining and exception groups, in Errors and Exceptions. Its section on user-defined exceptions uses classes, which you meet tomorrow on Day 16.
Reflection
Check each statement. If the answer is “not yet,” reread the linked subsection.
- I can read a traceback from the bottom up and find where and why a program stopped. Reread
- I can say what each common exception type means. Reread
- I can catch an exception with try and except and say which lines are skipped. Reread
- I can catch specific exception types, read the message with as, and explain why a bare except is a mistake. Reread
- I can use else and finally and predict when each runs. Reread
- I can follow an exception up through function calls to the handler that catches it. Reread
- I can raise a ValueError with a clear message from my own function. Reread
- I can define, raise, and catch a custom exception. Reread
- I can choose between checking first and trying first, and explain the choice. Reread