Aller au contenu principal

Lab 08 — Robust CLI App with Error Handling & Logging

Hands-on Lab 45 min Intermediate

Objectives

  1. Define a custom exception hierarchy
  2. Validate user input with try/except
  3. Configure the logging module with file and console handlers
  4. Persist data as JSON with error handling
  5. Use finally for 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:

  1. Add a contact with invalid email → ValidationError
  2. Add duplicate email → DuplicateContactError
  3. Delete non-existent contact → ContactNotFoundError
  4. Check contacts.log for all debug messages

Summary

  • Custom exception hierarchy: ContactErrorValidationError, DuplicateContactError, ContactNotFoundError
  • RotatingFileHandler for production log files
  • logger.exception() captures full stack traces in log file
  • finally block ensures session-end logging even on KeyboardInterrupt
  • Validation functions raise specific exceptions for clean error handling