Challenge 1 Extensions: Custom Exceptions, Decorators, and Generators
Objectives
By the end of this chapter, you should be able to:
- Extend an existing program with a custom exception
- Add a decorator to log actions taken on your data
- Use a generator to search your data lazily
💡 Why this matters: Real projects grow feature by feature, on top of code that already works. These extensions are optional, but each one is a chance to reach back into an earlier module and apply it somewhere that isn’t a standalone exercise.
Extension 1: A Custom Exception for Duplicates
Right now, add_contact will happily add two contacts with the same email. Fix that with a custom exception, the way you did back in the OOP module:
class DuplicateContactError(Exception):
pass
Update add_contact to check for an existing contact with the same email first, and raise DuplicateContactError if it finds one:
def add_contact(self, contact):
for existing in self.contacts:
if existing.email == contact.email:
raise DuplicateContactError(f"{contact.email} is already in your contact book")
self.contacts.append(contact)
Then handle it wherever you’re calling add_contact:
try:
book.add_contact(new_contact)
except DuplicateContactError as e:
print(f"Couldn't add contact: {e}")
Extension 2: Logging with a Decorator
Add a decorator that logs every time a contact is added or removed: a realistic use of decorators, beyond the toy examples from that module.
from functools import wraps
def log_action(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print(f"[LOG] {func.__name__} was called")
return result
return wrapper
Apply it to add_contact and remove_contact:
@log_action
def add_contact(self, contact):
...
@log_action
def remove_contact(self, name):
...
Bonus: extend log_action to also log which contact was affected, not just which method ran. You’ll need to look at args to do it.
Extension 3: Searching Lazily with a Generator
all_contacts() returns everything at once. Add a generator method instead, for searching without building an entire filtered list upfront:
def search_contacts(self, keyword):
for contact in self.contacts:
if keyword.lower() in contact.name.lower():
yield contact
for match in book.search_contacts("er"):
print(match)
For a contact book with a handful of entries this barely matters, but the same pattern is exactly how you’d handle a search across a much larger dataset without loading every match into memory at once.
Try It
- Add
DuplicateContactErrorand confirm adding the same email twice raises it, while adding a genuinely new contact still works. - Apply
log_actiontoadd_contactandremove_contact, and confirm a log line prints for each. - Add
search_contactsas a generator method, and loop over its results with aforloop. - Pick one more idea of your own (an
update_contactmethod, a command-line menu loop, an__eq__method onContact) and add it.
Recap
You’ve now taken a single working project and extended it with a custom exception, a decorator, and a generator: three tools from earlier in the course, applied to real code instead of an isolated example.
Next challenge: a command-line Inventory Manager, pulling in abstract base classes, recursion, logging, and argparse.