We’ll walk through a small CLI utility you might actually use: read a CSV of contacts, normalise emails, flag duplicates, and write a cleaned file. It pulls together packaging, pathlib, csv, argparse, logging, and pytest.
What we are building
- Command:
contact-clean input.csv -o cleaned.csv - Normalise email addresses (strip whitespace, lowercase).
- Report duplicate emails without crashing.
- Exit code 1 on missing input or write failure.
Project layout
contact_clean/
├── pyproject.toml
├── src/contact_clean/
│ ├── __init__.py
│ ├── cleaner.py
│ └── cli.py
└── tests/
├── sample.csv
└── test_cleaner.py
Core logic in cleaner.py
import csv
from pathlib import Path
def normalise_email(value: str) -> str:
return value.strip().lower()
def clean_contacts(rows: list[dict[str, str]]) -> tuple[list[dict[str, str]], list[str]]:
seen: set[str] = set()
cleaned: list[dict[str, str]] = []
duplicates: list[str] = []
for row in rows:
email = normalise_email(row.get("email", ""))
if not email:
continue
if email in seen:
duplicates.append(email)
continue
seen.add(email)
cleaned.append({"name": row.get("name", "").strip(), "email": email})
return cleaned, duplicates
File I/O wrapper
def load_csv(path: Path) -> list[dict[str, str]]:
with path.open(encoding="utf-8", newline="") as handle:
return list(csv.DictReader(handle))
def save_csv(path: Path, rows: list[dict[str, str]]) -> None:
fieldnames = ["name", "email"]
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
CLI layer
import argparse
import logging
import sys
from pathlib import Path
from contact_clean.cleaner import clean_contacts, load_csv, save_csv
log = logging.getLogger(__name__)
def main(argv: list[str] | None = None) -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
parser = argparse.ArgumentParser()
parser.add_argument("input", type=Path)
parser.add_argument("-o", "--output", type=Path, required=True)
args = parser.parse_args(argv)
if not args.input.exists():
log.error("input file not found: %s", args.input)
return 1
rows = load_csv(args.input)
cleaned, duplicates = clean_contacts(rows)
save_csv(args.output, cleaned)
if duplicates:
log.warning("skipped %s duplicate emails", len(duplicates))
log.info("wrote %s contacts to %s", len(cleaned), args.output)
return 0
Tests that matter
from contact_clean.cleaner import clean_contacts
def test_clean_contacts_removes_duplicates():
rows = [
{"name": "Alice", "email": "alice@example.com"},
{"name": "Alias", "email": "Alice@Example.com"},
]
cleaned, duplicates = clean_contacts(rows)
assert len(cleaned) == 1
assert duplicates == ["alice@example.com"]
Stretch goals
- Add
--jsonoutput for scripting. - Read config for default output directory.
- Add a
--verboseflag that sets DEBUG logging.
When this works end to end, you have a portfolio-friendly utility and a template for future scripts. Next: a small Flask app with forms and a database.

