Codeskill

Learn to code, step by step

Mini project CLI utility

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 --json output for scripting.
  • Read config for default output directory.
  • Add a --verbose flag 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.

PreviousCLI tools argparse/click