Python Raising Exceptions Practice Problems & Exercises
Practice: Raising Exceptions
← Back to lessonEasy
Write a function that validates an age value. It should raise TypeError when the input is not an integer, and ValueError when the integer is outside the range 0-150. Return the age unchanged if valid.
This tests the most fundamental raise pattern: checking a condition and raising with a descriptive message.
def validate_age(age):
if not isinstance(age, int):
raise TypeError(
"age must be int, got " + repr(type(age).__name__)
)
if age < 0 or age > 150:
raise ValueError(
"age must be between 0 and 150, got " + str(age)
)
return age
print(validate_age(25))
try:
validate_age("twenty")
except TypeError as e:
print("Caught TypeError:", e)
try:
validate_age(-5)
except ValueError as e:
print("Caught ValueError:", e)
try:
validate_age(200)
except ValueError as e:
print("Caught ValueError:", e)Solution
def validate_age(age):
if not isinstance(age, int):
raise TypeError(
"age must be int, got " + repr(type(age).__name__)
)
if age < 0 or age > 150:
raise ValueError(
"age must be between 0 and 150, got " + str(age)
)
return age
The raise statement creates an exception instance and immediately unwinds the call stack. TypeError signals that the caller passed the wrong type; ValueError signals the right type but an invalid value. Including the actual bad value in the message (got -5) tells the caller exactly what went wrong without needing to attach a debugger. This is Form 1 of raise: create and raise in one step.
def validate_age(age):
"""Validate that age is a non-negative integer.
Raise TypeError if age is not an int.
Raise ValueError if age is negative or greater than 150.
Return the age if valid.
"""
# TODO: implement
passExpected Output
25
Caught TypeError: age must be int, got 'str'
Caught ValueError: age must be between 0 and 150, got -5
Caught ValueError: age must be between 0 and 150, got 200Hints
Hint 1: Use isinstance(age, int) to check the type. Remember that bool is a subclass of int, but for this exercise you can ignore that.
Hint 2: Raise TypeError for wrong types and ValueError for out-of-range values. Include the bad value in the message.
Write a function that divides two numbers. If a ZeroDivisionError occurs, log the error message to a list, then re-raise the original exception using bare raise. The bare raise preserves the original traceback.
Test with (10, 2) for the happy path, then (10, 0) to trigger the error.
def safe_divide(a, b, log):
try:
return a / b
except ZeroDivisionError as e:
log.append(str(e))
raise
error_log = []
print(safe_divide(10, 2, error_log))
try:
safe_divide(10, 0, error_log)
except ZeroDivisionError as e:
print("Caught:", e)
print("Log:", error_log)Solution
def safe_divide(a, b, log):
try:
return a / b
except ZeroDivisionError as e:
log.append(str(e))
raise
Bare raise re-raises the current exception with its original traceback intact. This is the correct way to log-and-propagate: the caller sees the exception as if it was never caught, with the traceback pointing to the original a / b line. If you wrote raise e instead, Python would create a new traceback starting at the raise e line, losing the original location. Always use bare raise when re-raising.
def safe_divide(a, b, log):
"""Divide a by b.
If ZeroDivisionError occurs, append the error message
string to the log list, then re-raise the ORIGINAL
exception using bare raise.
Return the result if no error.
"""
# TODO: implement
passExpected Output
5.0
Caught: division by zero
Log: ['division by zero']Hints
Hint 1: Wrap the division in try/except ZeroDivisionError.
Hint 2: Inside the except block, append str(e) to the log list. Then use bare raise (just the word raise, no argument) to re-raise.
Write an email validator that builds exception instances as separate variables before raising them. This tests Form 2 of raise: pre-creating the exception object when the message requires complex logic.
Test with "[email protected]", 42, "alice", and "@example.com".
def validate_email(email):
if not isinstance(email, str):
exc = TypeError(
"email must be str, got " + repr(type(email).__name__)
)
raise exc
at_count = email.count("@")
if at_count != 1:
exc = ValueError(
"email must contain exactly one '@', got "
+ str(at_count) + " in " + repr(email)
)
raise exc
local, domain = email.split("@")
if not local or not domain:
exc = ValueError(
"email must have non-empty local and domain parts, got "
+ repr(email)
)
raise exc
return email
print(validate_email("[email protected]"))
try:
validate_email(42)
except TypeError as e:
print("Caught TypeError:", e)
try:
validate_email("alice")
except ValueError as e:
print("Caught ValueError:", e)
try:
validate_email("@example.com")
except ValueError as e:
print("Caught ValueError:", e)Solution
def validate_email(email):
if not isinstance(email, str):
exc = TypeError(
"email must be str, got " + repr(type(email).__name__)
)
raise exc
at_count = email.count("@")
if at_count != 1:
exc = ValueError(
"email must contain exactly one '@', got "
+ str(at_count) + " in " + repr(email)
)
raise exc
local, domain = email.split("@")
if not local or not domain:
exc = ValueError(
"email must have non-empty local and domain parts, got "
+ repr(email)
)
raise exc
return email
Form 2 of raise separates exception construction from raising. Writing exc = ValueError(...) then raise exc is functionally identical to raise ValueError(...), but it is cleaner when the message requires multi-line string building or conditional logic. The exception object exists as a regular Python object before raise transfers it to the exception handling machinery.
def validate_email(email):
"""Validate a simple email string.
Rules:
- Must be a string (raise TypeError)
- Must contain exactly one '@' (raise ValueError)
- Must have non-empty parts before and after '@' (raise ValueError)
Build the ValueError with a detailed message BEFORE raising it.
"""
# TODO: implement
passExpected Output
[email protected]
Caught TypeError: email must be str, got 'int'
Caught ValueError: email must contain exactly one '@', got 0 in 'alice'
Caught ValueError: email must have non-empty local and domain parts, got '@example.com'Hints
Hint 1: Count the "@" characters with email.count("@"). If not exactly 1, build and raise a ValueError.
Hint 2: Split on "@" and check that both parts are non-empty strings. Build the exception object first, assign to a variable, then raise it.
Write a function with guard clauses that validate both parameters before doing any work. The guards should check types first, then values. Only after all guards pass should the function compute the result.
This tests the "fail fast" principle: catch invalid inputs at the boundary.
def create_rectangle(width, height):
if not isinstance(width, (int, float)):
raise TypeError(
"width must be numeric, got " + repr(type(width).__name__)
)
if not isinstance(height, (int, float)):
raise TypeError(
"height must be numeric, got " + repr(type(height).__name__)
)
if width <= 0:
raise ValueError("width must be positive, got " + str(width))
if height <= 0:
raise ValueError("height must be positive, got " + str(height))
return {"width": width, "height": height, "area": width * height}
print(create_rectangle(5, 3))
try:
create_rectangle("five", 3)
except TypeError as e:
print("Caught TypeError:", e)
try:
create_rectangle(5, -2)
except ValueError as e:
print("Caught ValueError:", e)
try:
create_rectangle(0, 3)
except ValueError as e:
print("Caught ValueError:", e)Solution
def create_rectangle(width, height):
if not isinstance(width, (int, float)):
raise TypeError(
"width must be numeric, got " + repr(type(width).__name__)
)
if not isinstance(height, (int, float)):
raise TypeError(
"height must be numeric, got " + repr(type(height).__name__)
)
if width <= 0:
raise ValueError("width must be positive, got " + str(width))
if height <= 0:
raise ValueError("height must be positive, got " + str(height))
return {"width": width, "height": height, "area": width * height}
Guard clauses validate inputs at the very top of a function, before any real work begins. Each guard is a simple if bad: raise pattern. The order matters: check types first (TypeError), then check values (ValueError). After all guards pass, the function body runs with guaranteed-valid inputs. This eliminates the need for defensive checks deeper in the code and makes the function's contract explicit.
def create_rectangle(width, height):
"""Create a rectangle dict with width, height, and area.
Guard clauses:
- width must be a positive number (int or float)
- height must be a positive number (int or float)
Raise TypeError for non-numeric, ValueError for non-positive.
Return dict with keys: 'width', 'height', 'area'
"""
# TODO: implement with guard clauses at the top
passExpected Output
{'width': 5, 'height': 3, 'area': 15}
Caught TypeError: width must be numeric, got 'str'
Caught ValueError: height must be positive, got -2
Caught ValueError: width must be positive, got 0Hints
Hint 1: Put all validation checks at the very top of the function, before any computation.
Hint 2: Check isinstance(width, (int, float)) for type, then width <= 0 for value. Same for height. After all guards pass, compute and return the dict.
Medium
Write a config loader that translates low-level json.JSONDecodeError into a domain-specific ConfigError using explicit exception chaining (raise ... from e). When the JSON is valid but not a dict, raise ConfigError without chaining.
Check __cause__ to verify chaining works correctly.
import json
class ConfigError(Exception):
pass
def load_config(raw_json):
try:
result = json.loads(raw_json)
except json.JSONDecodeError as e:
raise ConfigError(
"Invalid config format: " + str(e)
) from e
if not isinstance(result, dict):
raise ConfigError(
"Config must be a JSON object, got "
+ type(result).__name__
)
return result
print(load_config('{"host": "localhost", "port": 8080}'))
try:
load_config("not json at all")
except ConfigError as e:
print("Caught ConfigError:", e)
print("Has __cause__:", e.__cause__ is not None)
print("Original error type:", type(e.__cause__).__name__)
try:
load_config('[1, 2, 3]')
except ConfigError as e:
print("Caught ConfigError:", e)
print("Has __cause__:", e.__cause__ is not None)Solution
import json
class ConfigError(Exception):
pass
def load_config(raw_json):
try:
result = json.loads(raw_json)
except json.JSONDecodeError as e:
raise ConfigError(
"Invalid config format: " + str(e)
) from e
if not isinstance(result, dict):
raise ConfigError(
"Config must be a JSON object, got "
+ type(result).__name__
)
return result
raise X from Y sets X.__cause__ = Y and produces the traceback message "The above exception was the direct cause of the following exception." This is explicit chaining: the caller sees both the high-level domain error (ConfigError) and the root technical cause (JSONDecodeError). When there is no underlying exception to chain from (the not a dict case), you raise without from and __cause__ is None. Use explicit chaining in library code whenever you translate a low-level exception into a domain-specific one.
class ConfigError(Exception):
"""Domain-specific configuration error."""
pass
def load_config(raw_json):
"""Parse a JSON config string.
If JSON parsing fails, raise ConfigError chained
from the original json.JSONDecodeError.
If the parsed result is not a dict, raise ConfigError
(no chaining needed).
Return the parsed config dict.
"""
import json
# TODO: implement
passExpected Output
{'host': 'localhost', 'port': 8080}
Caught ConfigError: Invalid config format: Expecting value: line 1 column 1 (char 0)
Has __cause__: True
Original error type: JSONDecodeError
Caught ConfigError: Config must be a JSON object, got list
Has __cause__: FalseHints
Hint 1: Wrap json.loads() in a try/except json.JSONDecodeError. In the except block, raise ConfigError(...) from e.
Hint 2: After successful parsing, check isinstance(result, dict). If not, raise ConfigError without chaining since there is no underlying exception to chain from.
Build an Inventory class that hides its internal dict implementation from callers. When a lookup fails, the internal KeyError should be suppressed with from None so callers only see the domain-specific ItemNotFoundError.
Verify that __cause__ is None and __suppress_context__ is True.
class ItemNotFoundError(Exception):
pass
class Inventory:
def __init__(self):
self._items = {}
def add(self, name, quantity):
self._items[name] = quantity
def get_quantity(self, name):
try:
return self._items[name]
except KeyError:
raise ItemNotFoundError(
"Item " + repr(name) + " not found in inventory"
) from None
def remove(self, name, amount):
current = self.get_quantity(name)
if amount > current:
raise ValueError(
"Cannot remove " + str(amount) + " units of "
+ repr(name) + " (only " + str(current)
+ " available)"
)
self._items[name] = current - amount
inv = Inventory()
inv.add("apple", 50)
print(inv.get_quantity("apple"))
try:
inv.get_quantity("banana")
except ItemNotFoundError as e:
print("Caught:", e)
print("__cause__ is None:", e.__cause__ is None)
print("__suppress_context__:", e.__suppress_context__)
try:
inv.remove("apple", 200)
except ValueError as e:
print("Caught:", e)Solution
class ItemNotFoundError(Exception):
pass
class Inventory:
def __init__(self):
self._items = {}
def add(self, name, quantity):
self._items[name] = quantity
def get_quantity(self, name):
try:
return self._items[name]
except KeyError:
raise ItemNotFoundError(
"Item " + repr(name) + " not found in inventory"
) from None
def remove(self, name, amount):
current = self.get_quantity(name)
if amount > current:
raise ValueError(
"Cannot remove " + str(amount) + " units of "
+ repr(name) + " (only " + str(current)
+ " available)"
)
self._items[name] = current - amount
raise X from None sets __cause__ = None and __suppress_context__ = True, hiding the internal exception entirely. Without from None, Python would show "During handling of the above exception, another exception occurred:" followed by the KeyError, leaking the fact that your class uses a dict internally. With from None, the caller only sees ItemNotFoundError. This is proper encapsulation: if you later switch from a dict to a database, the public interface stays the same.
class ItemNotFoundError(Exception):
"""Raised when a lookup fails in the inventory."""
pass
class Inventory:
def __init__(self):
self._items = {}
def add(self, name, quantity):
self._items[name] = quantity
def get_quantity(self, name):
"""Return the quantity of an item.
If item not found, raise ItemNotFoundError.
Suppress the internal KeyError — it is an
implementation detail.
"""
# TODO: implement
pass
def remove(self, name, amount):
"""Remove amount units of an item.
Raise ItemNotFoundError if item missing.
Raise ValueError if removing more than available.
Suppress internal KeyError.
"""
# TODO: implement
passExpected Output
50
Caught: Item 'banana' not found in inventory
__cause__ is None: True
__suppress_context__: True
Caught: Cannot remove 200 units of 'apple' (only 50 available)Hints
Hint 1: In get_quantity, wrap self._items[name] in try/except KeyError. In the except block, raise ItemNotFoundError(...) from None.
Hint 2: In remove, call get_quantity first (it handles the not-found case). Then check if amount exceeds the available quantity and raise ValueError if so.
Implement two lookup functions demonstrating the find vs get naming convention. find_student returns None for missing entries (not-found is expected). get_student raises LookupError for missing entries (not-found is an error) and ValueError for invalid input.
This tests the design decision of when to raise vs when to return a sentinel.
def find_student(students, student_id):
for s in students:
if s["id"] == student_id:
return s
return None
def get_student(students, student_id):
if not isinstance(student_id, int) or student_id <= 0:
raise ValueError(
"student_id must be a positive int, got "
+ str(student_id)
)
for s in students:
if s["id"] == student_id:
return s
raise LookupError(
"No student found with id " + str(student_id)
)
students = [
{"id": 1, "name": "Alice", "grade": "A"},
{"id": 2, "name": "Bob", "grade": "B"},
]
print("find Alice:", find_student(students, 1))
print("find unknown:", find_student(students, 99))
print("get Bob:", get_student(students, 2))
try:
get_student(students, -1)
except ValueError as e:
print("get invalid: Caught ValueError:", e)
try:
get_student(students, 99)
except LookupError as e:
print("get missing: Caught LookupError:", e)Solution
def find_student(students, student_id):
for s in students:
if s["id"] == student_id:
return s
return None
def get_student(students, student_id):
if not isinstance(student_id, int) or student_id <= 0:
raise ValueError(
"student_id must be a positive int, got "
+ str(student_id)
)
for s in students:
if s["id"] == student_id:
return s
raise LookupError(
"No student found with id " + str(student_id)
)
The find vs get convention communicates the contract through naming. find_X returns None when "not found" is a normal, expected outcome that callers handle routinely. get_X raises when "not found" means something is wrong. Providing both lets callers choose the behavior they need. get_student also validates input with a guard clause because invalid IDs are programmer errors, not normal outcomes. The key design question is: "Is not-found a normal case or an error?" The answer depends on the caller's context.
def find_student(students, student_id):
"""Search for a student by ID.
Return the student dict if found, None if not found.
This is the 'find' pattern — not-found is normal.
"""
# TODO: implement
pass
def get_student(students, student_id):
"""Get a student by ID.
Return the student dict if found.
Raise ValueError if student_id is not a positive int.
Raise LookupError if no student with that ID exists.
This is the 'get' pattern — not-found is an error.
"""
# TODO: implement
passExpected Output
find Alice: {'id': 1, 'name': 'Alice', 'grade': 'A'}
find unknown: None
get Bob: {'id': 2, 'name': 'Bob', 'grade': 'B'}
get invalid: Caught ValueError: student_id must be a positive int, got -1
get missing: Caught LookupError: No student found with id 99Hints
Hint 1: find_student simply iterates and returns the match, or returns None at the end. No exceptions.
Hint 2: get_student validates the input first (guard clause), then iterates. If the loop ends without finding the student, raise LookupError.
Write a hex color parser with exceptional error messages. Each message must include: (1) the bad value using repr, (2) what was expected, and (3) a hint for the most common mistake. This tests the art of writing error messages that help callers fix their code quickly.
def parse_color_hex(value):
if not isinstance(value, str):
raise TypeError(
"value must be str, got " + repr(type(value).__name__)
+ ". Did you mean '#FF0000' (a string)?"
)
if not value.startswith("#"):
raise ValueError(
"color must start with '#', got " + repr(value)
+ ". Did you forget the '#' prefix?"
)
if len(value) != 7:
raise ValueError(
"color must be 7 characters (#RRGGBB), got "
+ str(len(value)) + " characters in " + repr(value)
+ ". Did you use shorthand? Expand #RGB to #RRGGBB."
)
hex_part = value[1:]
try:
int(hex_part, 16)
except ValueError:
invalid = "".join(
c for c in hex_part if c not in "0123456789ABCDEFabcdef"
)
raise ValueError(
"color contains invalid hex characters: "
+ repr(invalid) + " in " + repr(value)
+ ". Valid hex digits are 0-9 and A-F."
) from None
r = int(hex_part[0:2], 16)
g = int(hex_part[2:4], 16)
b = int(hex_part[4:6], 16)
return (r, g, b)
print(parse_color_hex("#FF0000"))
print(parse_color_hex("#00FF00"))
for bad_input in [255, "FF0000", "#FFF", "#GG0000"]:
try:
parse_color_hex(bad_input)
except (TypeError, ValueError) as e:
print(type(e).__name__ + ":", e)Solution
def parse_color_hex(value):
if not isinstance(value, str):
raise TypeError(
"value must be str, got " + repr(type(value).__name__)
+ ". Did you mean '#FF0000' (a string)?"
)
if not value.startswith("#"):
raise ValueError(
"color must start with '#', got " + repr(value)
+ ". Did you forget the '#' prefix?"
)
if len(value) != 7:
raise ValueError(
"color must be 7 characters (#RRGGBB), got "
+ str(len(value)) + " characters in " + repr(value)
+ ". Did you use shorthand? Expand #RGB to #RRGGBB."
)
hex_part = value[1:]
try:
int(hex_part, 16)
except ValueError:
invalid = "".join(
c for c in hex_part if c not in "0123456789ABCDEFabcdef"
)
raise ValueError(
"color contains invalid hex characters: "
+ repr(invalid) + " in " + repr(value)
+ ". Valid hex digits are 0-9 and A-F."
) from None
r = int(hex_part[0:2], 16)
g = int(hex_part[2:4], 16)
b = int(hex_part[4:6], 16)
return (r, g, b)
A good error message answers three questions: what went wrong, what was expected, and what the caller probably meant to do. Using repr() on the bad value is critical: it shows quotes around strings (distinguishing '' from nothing), shows the type of non-string values, and makes whitespace visible. The hint ("Did you forget the '#' prefix?") anticipates the most common mistake for each validation. Production libraries like pydantic and click follow this pattern. The few extra seconds writing a good message saves minutes of debugging for every caller who triggers the error.
def parse_color_hex(value):
"""Parse a hex color string like '#FF0000' into (r, g, b) tuple.
Requirements for error messages:
- Include the bad value using repr
- Include what was expected
- Include a hint for common mistakes
Validations:
- Must be a string (TypeError)
- Must start with '#' (ValueError with hint)
- Must be exactly 7 chars long (ValueError)
- Must contain valid hex digits after '#' (ValueError)
Return tuple of (r, g, b) as integers 0-255.
"""
# TODO: implement
passExpected Output
(255, 0, 0)
(0, 255, 0)
TypeError: value must be str, got 'int'. Did you mean '#FF0000' (a string)?
ValueError: color must start with '#', got 'FF0000'. Did you forget the '#' prefix?
ValueError: color must be 7 characters (#RRGGBB), got 4 characters in '#FFF'. Did you use shorthand? Expand #RGB to #RRGGBB.
ValueError: color contains invalid hex characters: 'GG' in '#GG0000'. Valid hex digits are 0-9 and A-F.Hints
Hint 1: Use repr() or !r to show the bad value clearly, especially for empty strings or strings with whitespace.
Hint 2: For each validation, include three things: what went wrong, what was expected, and a hint for the most common mistake that would trigger this error.
Hard
Build a storage layer that translates all internal exceptions into domain-specific StorageError subclasses. Use from e when the original error has useful context, and from None when the original error is an implementation detail. This tests exception chaining decisions at architectural boundaries.
class StorageError(Exception):
pass
class RecordNotFoundError(StorageError):
pass
class StorageCorruptionError(StorageError):
pass
class InMemoryStore:
def __init__(self):
self._data = {}
def save(self, key, record):
if not isinstance(key, str) or not key:
raise StorageError(
"key must be a non-empty string, got " + repr(key)
)
if not isinstance(record, dict):
raise StorageError(
"record must be a dict, got "
+ type(record).__name__
)
self._data[key] = record
def load(self, key):
try:
value = self._data[key]
except KeyError:
raise RecordNotFoundError(
"No record with key " + repr(key)
) from None
if not isinstance(value, dict):
cause = TypeError(
"expected dict, got " + type(value).__name__
)
raise StorageCorruptionError(
"Record " + repr(key)
+ " is corrupted: expected dict, got "
+ type(value).__name__
) from cause
return value
def delete(self, key):
try:
del self._data[key]
except KeyError:
raise RecordNotFoundError(
"No record with key " + repr(key)
) from None
store = InMemoryStore()
store.save("user1", {"name": "Alice", "role": "admin"})
print("Saved and loaded:", store.load("user1"))
try:
store.load("unknown")
except RecordNotFoundError as e:
print("RecordNotFoundError:", e)
print(" __cause__ is None (suppressed):", e.__cause__ is None)
store._data["bad_key"] = "not a dict"
try:
store.load("bad_key")
except StorageCorruptionError as e:
print("StorageCorruptionError:", e)
print(" Has __cause__:", e.__cause__ is not None)
print(" __cause__ type:", type(e.__cause__).__name__)
store.delete("user1")
try:
store.load("user1")
except RecordNotFoundError as e:
print("RecordNotFoundError after delete:", e)
try:
store.delete("user1")
except StorageError as e:
print("Caught StorageError base class:", e)Solution
class StorageError(Exception):
pass
class RecordNotFoundError(StorageError):
pass
class StorageCorruptionError(StorageError):
pass
class InMemoryStore:
def __init__(self):
self._data = {}
def save(self, key, record):
if not isinstance(key, str) or not key:
raise StorageError(
"key must be a non-empty string, got " + repr(key)
)
if not isinstance(record, dict):
raise StorageError(
"record must be a dict, got "
+ type(record).__name__
)
self._data[key] = record
def load(self, key):
try:
value = self._data[key]
except KeyError:
raise RecordNotFoundError(
"No record with key " + repr(key)
) from None
if not isinstance(value, dict):
cause = TypeError(
"expected dict, got " + type(value).__name__
)
raise StorageCorruptionError(
"Record " + repr(key)
+ " is corrupted: expected dict, got "
+ type(value).__name__
) from cause
return value
def delete(self, key):
try:
del self._data[key]
except KeyError:
raise RecordNotFoundError(
"No record with key " + repr(key)
) from None
Exception translation layers are the backbone of clean architecture. At every layer boundary (storage, service, API), internal exceptions should be translated into domain-specific ones. The decision of from e vs from None is architectural: use from e when the caller benefits from seeing the root cause (corruption errors), use from None when the internal exception is a pure implementation detail (KeyError from a dict). Callers can catch the base StorageError for generic handling or specific subclasses for targeted recovery. This is the same pattern used by SQLAlchemy, requests, and Django's ORM.
class StorageError(Exception):
"""Base error for the storage layer."""
pass
class RecordNotFoundError(StorageError):
"""Raised when a record does not exist."""
pass
class StorageCorruptionError(StorageError):
"""Raised when stored data is invalid."""
pass
class InMemoryStore:
"""Simulates a storage backend with a dict.
Translates all internal exceptions into StorageError
subclasses using proper exception chaining.
"""
def __init__(self):
self._data = {}
def save(self, key, record):
"""Save a record. Key must be a non-empty string.
Record must be a dict.
Raise TypeError with chaining if types are wrong.
"""
# TODO: implement
pass
def load(self, key):
"""Load a record by key.
Raise RecordNotFoundError (from None) if missing.
Raise StorageCorruptionError (from original) if
the stored value is not a dict.
"""
# TODO: implement
pass
def delete(self, key):
"""Delete a record by key.
Raise RecordNotFoundError (from None) if missing.
"""
# TODO: implement
passExpected Output
Saved and loaded: {'name': 'Alice', 'role': 'admin'}
RecordNotFoundError: No record with key 'unknown'
__cause__ is None (suppressed): True
StorageCorruptionError: Record 'bad_key' is corrupted: expected dict, got str
Has __cause__: True
__cause__ type: TypeError
RecordNotFoundError after delete: No record with key 'user1'
Caught StorageError base class: No record with key 'user1'Hints
Hint 1: In save(), validate types then store. In load(), catch KeyError and raise RecordNotFoundError from None. If the value is not a dict, raise StorageCorruptionError from TypeError.
Hint 2: In delete(), use dict.pop or try/except KeyError. RecordNotFoundError should suppress the chain since KeyError is an implementation detail.
Implement a multi-stage record processor where each stage uses a different exception handling strategy. This tests your ability to choose the right pattern for each situation: explicit chain, suppress chain, bare re-raise, or fresh raise.
import json
class AppError(Exception):
pass
def process_record(raw):
# Stage 1: Parse JSON — chain from original
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
raise AppError(
"Failed to parse record: " + str(e)
) from e
# Stage 2: Validate fields — suppress internal KeyError
try:
name = data["name"]
value = data["value"]
except KeyError as e:
raise AppError(
"Record missing required field " + str(e)
) from None
# Stage 3: Convert value — bare re-raise
try:
value = float(value)
except ValueError:
raise
# Stage 4: Business rule — fresh raise
if value <= 0:
raise AppError(
"Business rule violation: value must be positive, got "
+ str(value)
)
return {"name": name, "value": value}
# Happy path
print(process_record('{"name": "temperature", "value": 23.5}'))
# Stage 1: bad JSON
try:
process_record("not json")
except AppError as e:
print("Stage 1 - AppError:", e)
print(" Chained (__cause__):", type(e.__cause__).__name__)
# Stage 2: missing field
try:
process_record('{"name": "temp"}')
except AppError as e:
print("Stage 2 - AppError:", e)
print(" Suppressed (__cause__ is None):", e.__cause__ is None)
# Stage 3: bad value type
try:
process_record('{"name": "temp", "value": "abc"}')
except ValueError as e:
print("Stage 3 - ValueError:", e)
print(" (bare re-raise, original error)")
# Stage 4: negative value
try:
process_record('{"name": "temp", "value": -10}')
except AppError as e:
print("Stage 4 - AppError:", e)Solution
import json
class AppError(Exception):
pass
def process_record(raw):
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
raise AppError(
"Failed to parse record: " + str(e)
) from e
try:
name = data["name"]
value = data["value"]
except KeyError as e:
raise AppError(
"Record missing required field " + str(e)
) from None
try:
value = float(value)
except ValueError:
raise
if value <= 0:
raise AppError(
"Business rule violation: value must be positive, got "
+ str(value)
)
return {"name": name, "value": value}
Each stage uses a different strategy because each has different needs. Stage 1 chains (from e) because the JSON parse error details (line number, position) are valuable to the caller. Stage 2 suppresses (from None) because KeyError: 'value' is an implementation detail -- the caller does not care that you used dict access. Stage 3 bare re-raises because ValueError: could not convert string to float: 'abc' is already a perfect message that needs no translation. Stage 4 raises fresh because there is no underlying exception -- it is a business rule check. The decision framework: chain when the cause has useful context, suppress when it leaks internals, re-raise when the original is already clear, and raise fresh when detecting a new error.
import json
class AppError(Exception):
pass
def process_record(raw):
"""Process a JSON record string through multiple stages.
Each stage has different exception handling:
Stage 1 - Parse JSON:
On json.JSONDecodeError -> raise AppError FROM the original
(caller needs to see parsing details)
Stage 2 - Validate required fields ('name' and 'value'):
On missing field -> raise AppError FROM NONE
(KeyError is implementation detail)
Stage 3 - Convert value to float:
On ValueError -> BARE RE-RAISE
(already a clear error, no translation needed)
Stage 4 - Business rule: value must be positive:
On violation -> raise NEW AppError (no chaining)
Return dict with 'name' (str) and 'value' (float).
"""
# TODO: implement all four stages
passExpected Output
{'name': 'temperature', 'value': 23.5}
Stage 1 - AppError: Failed to parse record: Expecting value: line 1 column 1 (char 0)
Chained (__cause__): JSONDecodeError
Stage 2 - AppError: Record missing required field 'value'
Suppressed (__cause__ is None): True
Stage 3 - ValueError: could not convert string to float: 'abc'
(bare re-raise, original error)
Stage 4 - AppError: Business rule violation: value must be positive, got -10.0Hints
Hint 1: Stage 1: try json.loads, except JSONDecodeError as e, raise AppError(...) from e.
Hint 2: Stage 2: try accessing data["name"] and data["value"], except KeyError, raise AppError(...) from None.
Hint 3: Stage 3: try float(data["value"]), except ValueError, bare raise. Stage 4: if value <= 0, raise AppError (no from).
Build a validation framework that collects all errors before raising a single ValidationError. Most validators stop at the first error, forcing users to fix one issue at a time. This pattern collects every problem so the caller can fix them all at once.
This tests raising with rich context and the architectural decision of when to raise immediately (type check) vs collect and raise (field validations).
class ValidationError(Exception):
def __init__(self, errors):
self.errors = errors
super().__init__(
str(len(errors)) + " validation error(s)"
)
def validate_user_registration(data):
if not isinstance(data, dict):
raise TypeError(
"data must be a dict, got " + repr(type(data).__name__)
)
errors = []
# Username
if "username" not in data:
errors.append("Missing required field: 'username'")
else:
u = data["username"]
if not isinstance(u, str):
errors.append(
"username must be str, got " + type(u).__name__
)
elif len(u) < 3 or len(u) > 20:
errors.append(
"username must be 3-20 characters, got "
+ str(len(u))
)
elif not u.isalnum():
errors.append(
"username must be alphanumeric, got "
+ repr(u)
)
# Email
if "email" not in data:
errors.append("Missing required field: 'email'")
else:
e = data["email"]
if not isinstance(e, str):
errors.append(
"email must be str, got " + type(e).__name__
)
elif "@" not in e:
errors.append(
"email must contain '@', got " + repr(e)
)
# Age
if "age" not in data:
errors.append("Missing required field: 'age'")
else:
a = data["age"]
if not isinstance(a, int):
errors.append(
"age must be int, got " + type(a).__name__
)
elif a < 13 or a > 120:
errors.append(
"age must be between 13 and 120, got "
+ str(a)
)
# Password
if "password" not in data:
errors.append("Missing required field: 'password'")
else:
p = data["password"]
if not isinstance(p, str):
errors.append(
"password must be str, got " + type(p).__name__
)
elif len(p) < 8:
errors.append(
"password must be at least 8 characters, got "
+ str(len(p))
)
if errors:
raise ValidationError(errors)
return data
# Valid input
good = {
"username": "alice42",
"email": "[email protected]",
"age": 25,
"password": "secure123",
}
print("Valid:", validate_user_registration(good))
# Wrong type entirely
try:
validate_user_registration("not a dict")
except TypeError as e:
print("Caught TypeError:", e)
# Multiple field errors
try:
validate_user_registration({
"username": "ab",
"email": "not-an-email",
"age": 10,
"password": "123",
})
except ValidationError as e:
print("ValidationError:", e)
for err in e.errors:
print(" -", err)
# Mixed: bad format + missing field
try:
validate_user_registration({
"username": "user name!",
"email": "[email protected]",
"age": 25,
})
except ValidationError as e:
print("ValidationError:", e)
for err in e.errors:
print(" -", err)Solution
class ValidationError(Exception):
def __init__(self, errors):
self.errors = errors
super().__init__(
str(len(errors)) + " validation error(s)"
)
def validate_user_registration(data):
if not isinstance(data, dict):
raise TypeError(
"data must be a dict, got " + repr(type(data).__name__)
)
errors = []
if "username" not in data:
errors.append("Missing required field: 'username'")
else:
u = data["username"]
if not isinstance(u, str):
errors.append(
"username must be str, got " + type(u).__name__
)
elif len(u) < 3 or len(u) > 20:
errors.append(
"username must be 3-20 characters, got "
+ str(len(u))
)
elif not u.isalnum():
errors.append(
"username must be alphanumeric, got " + repr(u)
)
if "email" not in data:
errors.append("Missing required field: 'email'")
else:
e = data["email"]
if not isinstance(e, str):
errors.append(
"email must be str, got " + type(e).__name__
)
elif "@" not in e:
errors.append(
"email must contain '@', got " + repr(e)
)
if "age" not in data:
errors.append("Missing required field: 'age'")
else:
a = data["age"]
if not isinstance(a, int):
errors.append(
"age must be int, got " + type(a).__name__
)
elif a < 13 or a > 120:
errors.append(
"age must be between 13 and 120, got " + str(a)
)
if "password" not in data:
errors.append("Missing required field: 'password'")
else:
p = data["password"]
if not isinstance(p, str):
errors.append(
"password must be str, got " + type(p).__name__
)
elif len(p) < 8:
errors.append(
"password must be at least 8 characters, got "
+ str(len(p))
)
if errors:
raise ValidationError(errors)
return data
Collecting errors before raising is the standard pattern for form validation and API input validation. The key architectural decision is the two-tier approach: raise TypeError immediately for fundamentally wrong input (not a dict), but collect field-level errors and raise once at the end. Immediate raises are for programmer errors that make further validation impossible. Collected raises are for user-facing validations where showing all problems at once saves round-trips. This is exactly how Django forms, pydantic models, and marshmallow schemas work. The custom ValidationError carries structured error data (a list) rather than just a string, making it easy for callers to display errors next to their respective form fields.
class ValidationError(Exception):
"""Carries a list of error message strings."""
def __init__(self, errors):
self.errors = errors
super().__init__(
str(len(errors)) + " validation error(s)"
)
def validate_user_registration(data):
"""Validate a user registration dict.
Collect ALL validation errors before raising.
Required fields and rules:
- 'username': str, 3-20 chars, alphanumeric only
- 'email': str, must contain '@'
- 'age': int, between 13 and 120
- 'password': str, at least 8 chars
If data is not a dict, raise TypeError immediately.
Otherwise collect all errors into a list and raise
a single ValidationError at the end.
Return data unchanged if valid.
"""
# TODO: implement
passExpected Output
Valid: {'username': 'alice42', 'email': '[email protected]', 'age': 25, 'password': 'secure123'}
Caught TypeError: data must be a dict, got 'str'
ValidationError: 4 validation error(s)
- username must be 3-20 characters, got 2
- email must contain '@', got 'not-an-email'
- age must be between 13 and 120, got 10
- password must be at least 8 characters, got 3
ValidationError: 2 validation error(s)
- username must be alphanumeric, got 'user name!'
- Missing required field: 'password'Hints
Hint 1: Create an empty errors list. For each field, try to validate and append error strings to the list. At the end, if errors is non-empty, raise ValidationError(errors).
Hint 2: Check for missing fields with "if field not in data" and add a missing-field error. Only validate the value if the field exists.
