Warm-up · Activity 1 of 7
// I1.4 · ~35 min · Intermediate
Exception groups and notes
After this lesson you can collect several errors and raise them together, handle each kind with except*, and attach notes that say where an error happened.
You will be able to
- Collect errors in a loop, raise them as an ExceptionGroup, and read its traceback
- Handle parts of a group with except*, predicting which clauses run and what is left over
- Add context to an exception with add_note, and find the notes in __notes__ and the traceback
Predict · Activity 2 of 7
Predict before you read on: this raises two errors together. Its traceback has one line that describes the group itself. Which one?
errors = [ValueError("age must be a number"), KeyError("email")] raise ExceptionGroup("2 records failed", errors)Practice · Activity 3 of 7
Fill in the class that raises all the errors in the list together.
raise ____("3 rows failed", errors)raise ("3 rows failed", errors)Practice · Activity 4 of 7
One group, two except* clauses. What does this print?
try: raise ExceptionGroup("batch", [ValueError("a"), KeyError("b"), ValueError("c")]) except* ValueError as eg: print("values:", len(eg.exceptions), end="; ") except* KeyError as eg: print("keys:", len(eg.exceptions), end="; ")Practice · Activity 5 of 7
eg = ExceptionGroup("batch", [ValueError("a"), KeyError("b")]). Match each expression to its value.
Brain teaser · Activity 6 of 7
Brain teaser. Only the ValueError has an except* clause. What does this print?
try: try: raise ExceptionGroup("batch", [ValueError("a"), KeyError("b")]) except* ValueError: print("handled the ValueError", end=" | ") except ExceptionGroup as eg: print("left over:", eg.exceptions)Apply · Activity 7 of 7
Mini-task. Write check_prices(prices), where prices maps a name to a text such as "2.5". Convert each with float and reject negative prices. Do not stop at the first problem: add a note naming the item to each error, collect them, and raise one ExceptionGroup at the end. Handle it with except* ValueError, printing each message and its note.
Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
Checking a batch of sign-ups
check_all checks every sign-up and does not stop at the first bad one. Each error gets a note with its record number and goes into a list, and at the end one ExceptionGroup carries them all. The caller handles bad values and missing fields in separate except* clauses, and prints each error with its note.
main.py
def check(record: dict[str, str]) -> None:
if "email" not in record:
raise KeyError("email")
if not record["age"].isdigit():
raise ValueError(f"age must be a number, got {record['age']!r}")
def check_all(records: list[dict[str, str]]) -> None:
errors: list[Exception] = []
for number, record in enumerate(records, start=1):
try:
check(record)
except (ValueError, KeyError) as err:
err.add_note(f"in record {number}")
errors.append(err)
if errors:
raise ExceptionGroup(f"{len(errors)} of {len(records)} records failed", errors)
records = [
{"age": "34", "email": "ada@example.org"},
{"age": "x", "email": "bob@example.org"},
{"age": "51"},
{"age": "-3", "email": "eve@example.org"},
]
try:
check_all(records)
except* ValueError as bad_values:
print(bad_values.message)
for err in bad_values.exceptions:
print("bad value:", err, "|", err.__notes__[0])
except* KeyError as missing:
for key_err in missing.exceptions:
print("missing field:", key_err, "|", key_err.__notes__[0])
Run it with
python main.pyOutput
3 of 4 records failed
bad value: age must be a number, got 'x' | in record 2
bad value: age must be a number, got '-3' | in record 4
missing field: 'email' | in record 3- The group’s message is kept in the smaller group each except* clause receives.
- Both ValueErrors arrive together in one run of the first except* clause.
- After the ValueErrors are handled, the KeyError is left, so the second clause runs too.
- str(err) is only the message; the note is read from err.__notes__.
Change it and run it
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Exercises
Exercise 1 of 2
Collect instead of stopping
parse_all(texts) turns a list of texts into ints, but it stops at the first bad one. Make it check every text: add the note "item <n>" to each ValueError, counting from 1, collect them, and after the loop raise ExceptionGroup("some items are not numbers", errors). Return the list of ints when all texts are fine.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Hints
Hint 1
Loop with enumerate(texts, start=1), and put int(text) in a try inside the loop.
Hint 2
In except ValueError as err:, call err.add_note(f"item {n}") and append err to a list of errors.
Hint 3
After the loop: if errors: raise ExceptionGroup("some items are not numbers", errors). Otherwise return the numbers.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
def parse_all(texts: list[str]) -> list[int]:
numbers: list[int] = []
errors: list[ValueError] = []
for n, text in enumerate(texts, start=1):
try:
numbers.append(int(text))
except ValueError as err:
err.add_note(f"item {n}")
errors.append(err)
if errors:
raise ExceptionGroup("some items are not numbers", errors)
return numbers
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
def parse_all(texts: list[str]) -> list[int]:
return [int(text) for text in texts]
test_main.py
from main import parse_all
def test_all_good():
"""Good texts come back as ints"""
got = parse_all(["1", "22", "-3"])
assert got == [1, 22, -3], f"parse_all returned {got!r}"
def catch(texts):
try:
parse_all(texts)
except ExceptionGroup as eg:
return eg
except ValueError:
assert False, "parse_all stopped at the first ValueError; collect them into an ExceptionGroup"
assert False, "parse_all returned normally for bad input"
def test_group():
"""Two bad texts give one group with both errors"""
eg = catch(["1", "x", "3", "y"])
assert eg.message == "some items are not numbers", f"the group message is {eg.message!r}"
assert len(eg.exceptions) == 2, f"the group holds {len(eg.exceptions)} exceptions, expected 2"
assert all(isinstance(e, ValueError) for e in eg.exceptions), "every exception in the group should be a ValueError"
def test_notes():
"""Each error has a note with its position"""
eg = catch(["1", "x", "3", "y"])
notes = [getattr(e, "__notes__", []) for e in eg.exceptions]
assert notes == [["item 2"], ["item 4"]], f"the notes are {notes!r}"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 2 of 2
Sort out the group
check_all is done: it raises an ExceptionGroup of ValueErrors and KeyErrors, each with a note. Write summarize(records), which returns a list of lines: "bad value: <message> (<note>)" for each ValueError, then "missing field: <key> (<note>)" for each KeyError, using except*. If check_all raises nothing, return ["all valid"]. Let any other error through.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Hints
Hint 1
Start with an empty list lines, then try: check_all(records) with two except* clauses: except* ValueError as bad_values: and except* KeyError as missing:.
Hint 2
In each clause, loop over the group’s exceptions and append an f-string with the error and its __notes__[0]. Use new names in the second clause, or mypy complains. return is not allowed inside except*.
Hint 3
After the try statement, return lines or ["all valid"]: an empty list is false.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
def check(record: dict[str, str]) -> None:
if "email" not in record:
raise KeyError("email")
if not record["age"].isdigit():
raise ValueError(f"age must be a number, got {record['age']!r}")
def check_all(records: list[dict[str, str]]) -> None:
errors: list[Exception] = []
for number, record in enumerate(records, start=1):
try:
check(record)
except (ValueError, KeyError) as err:
err.add_note(f"in record {number}")
errors.append(err)
if errors:
raise ExceptionGroup(f"{len(errors)} records failed", errors)
def summarize(records: list[dict[str, str]]) -> list[str]:
lines: list[str] = []
try:
check_all(records)
except* ValueError as bad_values:
for err in bad_values.exceptions:
lines.append(f"bad value: {err} ({err.__notes__[0]})")
except* KeyError as missing:
for key_err in missing.exceptions:
lines.append(f"missing field: {key_err} ({key_err.__notes__[0]})")
return lines or ["all valid"]
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
def check(record: dict[str, str]) -> None:
if "email" not in record:
raise KeyError("email")
if not record["age"].isdigit():
raise ValueError(f"age must be a number, got {record['age']!r}")
def check_all(records: list[dict[str, str]]) -> None:
errors: list[Exception] = []
for number, record in enumerate(records, start=1):
try:
check(record)
except (ValueError, KeyError) as err:
err.add_note(f"in record {number}")
errors.append(err)
if errors:
raise ExceptionGroup(f"{len(errors)} records failed", errors)
def summarize(records: list[dict[str, str]]) -> list[str]:
check_all(records)
return ["all valid"]
test_main.py
from main import summarize
def test_all_valid():
"""Valid records give all valid"""
got = summarize([{"age": "34", "email": "ada@example.org"}])
assert got == ["all valid"], f"summarize returned {got!r}"
def test_both_kinds():
"""Bad values come first, then missing fields, each with its note"""
records = [{"age": "x", "email": "a@b"}, {"age": "3"}, {"age": "-3", "email": "c@d"}]
got = summarize(records)
want = [
"bad value: age must be a number, got 'x' (in record 1)",
"bad value: age must be a number, got '-3' (in record 3)",
"missing field: 'email' (in record 2)",
]
assert got == want, f"summarize returned {got!r}"
def test_only_missing():
"""A group with only KeyErrors is handled too"""
got = summarize([{"age": "1"}, {"age": "2"}])
assert got == ["missing field: 'email' (in record 1)", "missing field: 'email' (in record 2)"], f"summarize returned {got!r}"
def test_other_errors_pass():
"""Other errors are not caught"""
try:
summarize([{"age": 5, "email": "a@b"}]) # type: ignore[dict-item]
except AttributeError:
pass
else:
assert False, "summarize swallowed an AttributeError"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyCommon mistakes
Mixing except and except*
try:
raise ExceptionGroup("batch", [ValueError("a")])
except* ValueError:
print("values")
except KeyError:
print("keys")
What Python prints
SyntaxError: cannot have both 'except' and 'except*' on the same 'try'Why, and the fix
A try statement uses one kind of clause. Inside a group, KeyError is also caught with except* KeyError:. To catch the whole group at once instead, use a plain except ExceptionGroup: in a try statement of its own.
except* with ExceptionGroup
try:
raise ExceptionGroup("batch", [ValueError("a")])
except* ExceptionGroup:
print("caught")
What Python prints
TypeError: catching ExceptionGroup with except* is not allowed. Use except instead.Why, and the fix
except* looks at the exceptions inside a group, so naming the group type there has no clear meaning, and Python refuses it when the clause is reached. To catch a group as a whole, write except ExceptionGroup as eg:.
return inside except*
def load():
try:
raise ExceptionGroup("batch", [ValueError("a")])
except* ValueError:
return "handled"
What Python prints
SyntaxError: 'break', 'continue' and 'return' cannot appear in an except* blockWhy, and the fix
Several except* clauses may still have to run after this one, so leaving early is not allowed. Store the result in a variable inside the clause and return it after the try statement.
A note that is not a string
err = ValueError("bad age")
err.add_note(42)
What Python prints
TypeError: add_note() argument must be str, not intWhy, and the fix
Notes are text shown in the traceback, so add_note accepts only a string. Build one with an f-string: err.add_note(f"in record {42}").
Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source
Exit ticket
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.