DEV Community

Cover image for The Quiet Design Choices Behind a 50-Line CLI Tool
Charles Adesoba
Charles Adesoba

Posted on

The Quiet Design Choices Behind a 50-Line CLI Tool

I'm building a CLI tool that scans a directory and sorts files into folders by type. Small in scope, but every small tool still forces real design decisions. Here are three from this week, and the reasoning behind each.

pathlib over os.path
I chose pathlib over os.path for one core reason: paths aren't strings, and treating them as strings invites bugs a purpose-built API already solves. os.path returns plain strings, so any extension logic has to be hand-rolled, and compound extensions like archive.tar.gz are where that breaks down.

# os.path — a naive split silently mishandles compound extensions
name, ext = os.path.splitext("archive.tar.gz")
# ext == '.gz'  — the '.tar' part is just gone unless you call splitext again

# pathlib — the same gap exists, but it's explicit, not hidden
Path("archive.tar.gz").suffix     # '.gz'   — last suffix only
Path("archive.tar.gz").suffixes   # ['.tar', '.gz']  — all of them, if you ask for them
Enter fullscreen mode Exit fullscreen mode

pathlib doesn't make compound extensions disappear as a problem — .suffix still only gives you the last one. What it gives you is a typed, documented way to choose which behaviour you want (.suffix vs .suffixes) instead of guessing at string-split indices and finding out you guessed wrong when a .tar.gz file quietly gets misclassified.

Why dry run exist before any destructive operation?
I added a dry-run flag because moving files isn't trivially reversible, and previewing an operation is free. On a personal machine with well-understood files, a wrong move is a minor inconvenience. But the same operation gets genuinely risky once files are shared, moved across filesystems (where the operation isn't atomic), or simply irreplaceable. The flag doesn't assume which situation you're in; it just makes the cost of finding out in advance effectively zero.

$ python3 main.py --source ~/downloads --dry-run
[DRY RUN] invoice.pdf -> Documents/
[DRY RUN] photo.jpg -> Images/
[DRY RUN] script.py -> Code/
Enter fullscreen mode Exit fullscreen mode

A dry run is only trustworthy if it can't lie to you. The branch that decides what file goes where runs identically in both modes; classification happens before the dry-run check, not inside it. The only thing the flag changes is the last step: print the destination instead of calling the move. Same logic, same output shape, one line different.

A dict, not an if/elif chain
An if/elif chain fuses data and logic — adding a new file type means editing the function's control flow directly, which risks breaking existing, already-tested branches.

# if/elif — data and logic tangled together
if ext == ".pdf":
    return "Documents"
elif ext == ".jpg":
    return "Images"
elif ext == ".py":
    return "Code"
# every new type = another branch, edited into working code

# dict — data and logic separated
EXTENSION_MAP = {".pdf": "Documents", ".jpg": "Images", ".py": "Code"}
return EXTENSION_MAP.get(ext, "Other")
# every new type = one line added to a table; the lookup never changes
Enter fullscreen mode Exit fullscreen mode

The practical payoff isn't just tidiness. Because the mapping is plain data, it doesn't have to stay Python code at all; it's one small step away from living in a config.json a non-programmer could edit, instead of being buried inside a function only I can safely touch.

None of these decisions is novel on their own. What made them worth writing down is the habit of asking why before writing the obvious version, the one that works today but quietly costs more later.

Top comments (0)