Warm-up · Activity 1 of 7
// I1.2 · ~30 min · Intermediate
Exception chaining
After this lesson you can read a traceback that shows two exceptions, turn a low-level error into your own without losing it, and hide an original error that only adds noise.
You will be able to
- Read a chained traceback and tell "During handling" from "direct cause"
- Turn an error into your own with raise … from err, and find the original in __cause__ and __context__
- Hide an unhelpful original error with from None, knowing it is still kept
Predict · Activity 2 of 7
Predict before you read on: this stops with a traceback that shows two exceptions. Which line joins them?
settings = {} try: port = settings["port"] except KeyError: raise RuntimeError("config has no port")Practice · Activity 3 of 7
Fill in the keyword that makes the KeyError the direct cause of the ConfigError.
raise ConfigError("port is missing") ____ errraise ConfigError("port is missing") errPractice · Activity 4 of 7
What does this print?
try: try: 1 / 0 except ZeroDivisionError as err: raise ValueError("bad ratio") from err except ValueError as exc: print(type(exc.__cause__).__name__, type(exc.__context__).__name__)Practice · Activity 5 of 7
A ValueError is raised inside except KeyError. Match each raise to what its traceback shows.
Brain teaser · Activity 6 of 7
Brain teaser. from None hides the KeyError. Is it gone? What does this print?
def lookup(prices, item): try: return prices[item] except KeyError: raise LookupError(f"no price for {item}") from None try: lookup({}, "tea") except LookupError as err: print(err.__cause__, repr(err.__context__))Apply · Activity 7 of 7
Mini-task. Define PriceError and write parse_price(text), which returns float(text). When float fails, raise PriceError with the message bad price: and the text in repr form, chained to the ValueError with from. Call it with "2.50" and "two", and for the error print the message and repr(err.__cause__).
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
Loading settings: translate, hide, or let through
load_port reads a port from JSON text. It translates invalid JSON into SettingsError with from err, so the parse error stays visible as the cause. A missing key becomes SettingsError with from None, because the KeyError adds nothing. A port that is not a number is not handled at all, and its ValueError reaches the caller unchanged.
main.py
import json
class SettingsError(Exception):
pass
def load_port(text: str) -> int:
try:
data = json.loads(text)
except json.JSONDecodeError as err:
raise SettingsError("settings are not valid JSON") from err
try:
return int(data["port"])
except KeyError:
raise SettingsError("settings have no port") from None
for text in ['{"port": "8080"}', '{"port": 80', '{"host": "a"}', '{"port": "http"}']:
try:
port = load_port(text)
except SettingsError as err:
cause = type(err.__cause__).__name__
context = type(err.__context__).__name__
print(f"{err} | cause: {cause} | context: {context}")
except ValueError as err:
print("not a SettingsError:", err)
else:
print("port", port)
Run it with
python main.pyOutput
port 8080
settings are not valid JSON | cause: JSONDecodeError | context: JSONDecodeError
settings have no port | cause: NoneType | context: KeyError
not a SettingsError: invalid literal for int() with base 10: 'http'- With from err, __cause__ and __context__ are both the JSONDecodeError.
- With from None, __cause__ is None, but the KeyError is still in __context__.
- except KeyError does not catch the ValueError from int("http"), so it goes through untranslated.
- The caller catches SettingsError and never needs to know about json or KeyError.
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
Keep the cause
parse_price(text) already raises PriceError for text that float cannot read, and for a negative price. Change it so that the "bad price" PriceError is chained to the ValueError with from, so that err.__cause__ is that ValueError. The negative-price error has no cause and needs no change.
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
To use the ValueError, give it a name: except ValueError as err:.
Hint 2
Then add from err at the end of the raise line.
Hint 3
raise PriceError(f"bad price: {text!r}") from err
Show a solution
One way to solve it. Yours can look different and still pass the checks.
class PriceError(Exception):
pass
def parse_price(text: str) -> float:
try:
value = float(text)
except ValueError as err:
raise PriceError(f"bad price: {text!r}") from err
if value < 0:
raise PriceError(f"negative price: {value}")
return value
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
class PriceError(Exception):
pass
def parse_price(text: str) -> float:
try:
value = float(text)
except ValueError:
raise PriceError(f"bad price: {text!r}")
if value < 0:
raise PriceError(f"negative price: {value}")
return value
test_main.py
from main import PriceError, parse_price
def test_valid():
"""A valid price comes back as a float"""
got = parse_price("2.50")
assert got == 2.5, f"parse_price('2.50') returned {got!r}"
def test_bad_price_message():
"""Text that is not a number raises PriceError with a clear message"""
try:
parse_price("two")
except PriceError as err:
assert str(err) == "bad price: 'two'", f"the message was {str(err)!r}"
else:
assert False, "parse_price('two') returned normally"
def test_bad_price_cause():
"""The ValueError is the direct cause"""
try:
parse_price("two")
except PriceError as err:
assert isinstance(err.__cause__, ValueError), f"__cause__ is {err.__cause__!r}; use raise ... from err"
def test_negative():
"""A negative price raises PriceError without a cause"""
try:
parse_price("-1")
except PriceError as err:
assert str(err) == "negative price: -1.0", f"the message was {str(err)!r}"
assert err.__cause__ is None, f"__cause__ is {err.__cause__!r}, expected None"
else:
assert False, "parse_price('-1') returned normally"
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
Hide the lookup, keep the conversion
Write get_setting(settings, key). It returns settings[key]. For a missing key, raise SettingError(f"unknown setting: {key!r}") from None. Then write get_int_setting(settings, key), which returns int(get_setting(settings, key)). If int fails, raise SettingError(f"setting {key!r} is not a whole number") chained to the ValueError with from.
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
In get_setting, put return settings[key] in a try, and in except KeyError: raise SettingError(...) from None.
Hint 2
In get_int_setting, call get_setting outside the try, so its SettingError is not caught again. Put only int(text) inside the try.
Hint 3
except ValueError as err: raise SettingError(f"setting {key!r} is not a whole number") from err
Show a solution
One way to solve it. Yours can look different and still pass the checks.
class SettingError(Exception):
pass
def get_setting(settings: dict[str, str], key: str) -> str:
try:
return settings[key]
except KeyError:
raise SettingError(f"unknown setting: {key!r}") from None
def get_int_setting(settings: dict[str, str], key: str) -> int:
text = get_setting(settings, key)
try:
return int(text)
except ValueError as err:
raise SettingError(f"setting {key!r} is not a whole number") from err
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
class SettingError(Exception):
pass
def get_setting(settings: dict[str, str], key: str) -> str:
return settings[key]
def get_int_setting(settings: dict[str, str], key: str) -> int:
return int(get_setting(settings, key))
test_main.py
from main import SettingError, get_int_setting, get_setting
SETTINGS = {"port": "8080", "host": "example.org"}
def catch(f, key):
try:
f(SETTINGS, key)
except SettingError as err:
return err
assert False, f"{f.__name__}(settings, {key!r}) returned normally"
def test_values():
"""Existing settings come back"""
got = get_setting(SETTINGS, "host"), get_int_setting(SETTINGS, "port")
assert got == ("example.org", 8080), f"got {got!r}"
def test_unknown_hidden():
"""An unknown key raises SettingError from None"""
err = catch(get_setting, "user")
assert str(err) == "unknown setting: 'user'", f"the message was {str(err)!r}"
assert err.__cause__ is None and err.__suppress_context__, "the KeyError should be hidden with from None"
assert isinstance(err.__context__, KeyError), f"__context__ is {err.__context__!r}, expected the KeyError"
def test_not_a_number_chained():
"""A setting that is not a number is chained to the ValueError"""
err = catch(get_int_setting, "host")
assert str(err) == "setting 'host' is not a whole number", f"the message was {str(err)!r}"
assert isinstance(err.__cause__, ValueError), f"__cause__ is {err.__cause__!r}; use raise ... from err"
def test_unknown_int():
"""get_int_setting reports an unknown key the same way"""
err = catch(get_int_setting, "user")
assert str(err) == "unknown setting: 'user'", f"the message was {str(err)!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.pyCommon mistakes
A string after from
raise RuntimeError("could not load") from "file missing"
What Python prints
TypeError: exception causes must derive from BaseExceptionWhy, and the fix
The cause must be an exception object (or None), not a message. Catch the original with except OSError as err: and write raise RuntimeError("could not load") from err. To add words, put them in the new exception’s message.
Using the as name after the except clause
try:
int("x")
except ValueError as err:
print("bad number")
print(err)
What Python prints
NameError: name 'err' is not definedWhy, and the fix
Python deletes the name after as when the except clause ends. To use the exception later, for example to chain it or report it, assign it to another variable inside the clause: saved = err.
A bug inside the handler
prices = {"tea": 2.5}
try:
price = prices["coffee"]
except KeyError:
price = fallbak_price
What Python prints
NameError: name 'fallbak_price' is not definedWhy, and the fix
The traceback shows the KeyError, then During handling of the above exception, another exception occurred:, then this NameError. That joining line means the handler itself failed: here a misspelt name. Fix the handler, not the try block.
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.