Lab 08 — Robust CLI App with Error Handling & Logging
Hands-on Lab 45 min Intermediate
Objectives
- Define a custom exception hierarchy
- Validate user input with
try/except - Configure the
loggingmodule with file and console handlers - Persist data as JSON with error handling
- Use
finallyfor cleanup
Step 1 — Create the App
mkdir ~/python-course/lab-08 && cd ~/python-course/lab-08
Create contacts.py:
#!/usr/bin/env python3
"""
Lab 08 — Contact Manager with Error Handling & Logging
"""
import json
import logging
import re
from pathlib import Path
from logging.handlers import RotatingFileHandler
# ── Logging Setup ─────────────────────────────────────────────
def setup_logging():
formatter = logging.Formatter(
"%(asctime)s [%(levelname)-8s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
root = logging.getLogger()
root.setLevel(logging.DEBUG)
console = logging.StreamHandler()
console.setFormatter(formatter)
console.setLevel(logging.WARNING)
fh = RotatingFileHandler("contacts.log", maxBytes=1_000_000, backupCount=2)
fh.setFormatter(formatter)
fh.setLevel(logging.DEBUG)
root.addHandler(console)
root.addHandler(fh)
logger = logging.getLogger(__name__)
# ── Custom Exceptions ──────────────────────────────────────────
class ContactError(Exception):
"""Base exception for contact operations."""
class ValidationError(ContactError):
def __init__(self, field, value, msg):
self.field = field
self.value = value
super().__init__(f"[{field}] {msg} (got: {value!r})")
class DuplicateContactError(ContactError):
def __init__(self, email):
super().__init__(f"Contact with email {email!r} already exists")
class ContactNotFoundError(ContactError):
def __init__(self, query):
super().__init__(f"No contact found matching {query!r}")
# ── Validation ────────────────────────────────────────────────
def validate_name(name: str) -> str:
name = name.strip()
if len(name) < 2:
raise ValidationError("name", name, "must be at least 2 characters")
if not re.match(r"^[A-Za-z\s\-']+$", name):
raise ValidationError("name", name, "only letters, spaces, hyphens allowed")
return name
def validate_email(email: str) -> str:
email = email.strip().lower()
if not re.match(r"^[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}$", email):
raise ValidationError("email", email, "invalid email format")
return email
def validate_phone(phone: str) -> str:
cleaned = re.sub(r"[\s\-\(\)]", "", phone)
if not re.match(r"^\+?\d{10,15}$", cleaned):
raise ValidationError("phone", phone, "must have 10-15 digits")
return cleaned
# ── Contact Manager ───────────────────────────────────────────
class ContactManager:
DATA_FILE = Path("contacts.json")
def __init__(self):
self._contacts: list[dict] = []
self._load()
def _load(self):
if self.DATA_FILE.exists():
try:
self._contacts = json.loads(self.DATA_FILE.read_text(encoding="utf-8"))
logger.info("Loaded %d contacts from file", len(self._contacts))
except json.JSONDecodeError as e:
logger.error("Corrupt data file: %s", e)
self._contacts = []
def _save(self):
try:
self.DATA_FILE.write_text(
json.dumps(self._contacts, indent=2, ensure_ascii=False),
encoding="utf-8"
)
logger.debug("Saved %d contacts", len(self._contacts))
except OSError as e:
logger.error("Failed to save contacts: %s", e)
raise
def add(self, name: str, email: str, phone: str = "") -> dict:
name = validate_name(name)
email = validate_email(email)
if phone:
phone = validate_phone(phone)
if any(c["email"] == email for c in self._contacts):
raise DuplicateContactError(email)
contact = {"name": name, "email": email, "phone": phone}
self._contacts.append(contact)
self._save()
logger.info("Added contact: %s <%s>", name, email)
return contact
def find(self, query: str) -> list[dict]:
q = query.lower()
results = [
c for c in self._contacts
if q in c["name"].lower() or q in c["email"].lower()
]
logger.debug("Search '%s' returned %d results", query, len(results))
return results
def delete(self, email: str) -> dict:
email = validate_email(email)
for i, c in enumerate(self._contacts):
if c["email"] == email:
removed = self._contacts.pop(i)
self._save()
logger.info("Deleted contact: %s", email)
return removed
raise ContactNotFoundError(email)
def list_all(self) -> list[dict]:
return list(self._contacts)
# ── CLI ───────────────────────────────────────────────────────
def print_contacts(contacts):
if not contacts:
print(" (no contacts)")
return
print(f" {'Name':<25} {'Email':<30} {'Phone'}")
print(" " + "-" * 70)
for c in contacts:
print(f" {c['name']:<25} {c['email']:<30} {c.get('phone','')}")
def run_cli(manager: ContactManager):
print("\n📒 Contact Manager — Lab 08\n")
while True:
print("\n [1] Add contact [2] Search [3] List all [4] Delete [q] Quit")
choice = input(" > ").strip().lower()
if choice == "q":
break
elif choice == "1":
try:
name = input(" Name : ")
email = input(" Email : ")
phone = input(" Phone : ")
c = manager.add(name, email, phone)
print(f" ✅ Added: {c['name']} <{c['email']}>")
except (ValidationError, DuplicateContactError) as e:
print(f" ❌ {e}")
except Exception as e:
logger.exception("Unexpected error adding contact")
print(f" ❌ Unexpected error: {e}")
elif choice == "2":
query = input(" Search: ")
results = manager.find(query)
print_contacts(results)
elif choice == "3":
print_contacts(manager.list_all())
elif choice == "4":
email = input(" Email to delete: ")
try:
removed = manager.delete(email)
print(f" ✅ Deleted: {removed['name']}")
except (ValidationError, ContactNotFoundError) as e:
print(f" ❌ {e}")
print("\n Goodbye! 👋")
def main():
setup_logging()
manager = ContactManager()
try:
run_cli(manager)
except KeyboardInterrupt:
print("\n Interrupted")
finally:
logger.info("Session ended")
if __name__ == "__main__":
main()
Step 2 — Run and Test
python3 contacts.py
Test these error scenarios:
- Add a contact with invalid email →
ValidationError - Add duplicate email →
DuplicateContactError - Delete non-existent contact →
ContactNotFoundError - Check
contacts.logfor all debug messages
Summary
- Custom exception hierarchy:
ContactError→ValidationError,DuplicateContactError,ContactNotFoundError RotatingFileHandlerfor production log fileslogger.exception()captures full stack traces in log filefinallyblock ensures session-end logging even onKeyboardInterrupt- Validation functions raise specific exceptions for clean error handling