Your First Real Python Program
Building a small command-line tool end to end: arguments, files, error handling, structure and a first test — putting every earlier lesson into one thing that works.
Building a small command-line tool end to end: arguments, files, error handling, structure and a first test — putting every earlier lesson into one thing that works.
Sixteen lessons of pieces. This one builds a thing.
We are going to write the photo tool that has been the running example of this course: a command-line program that walks a folder, reads what it finds, produces captions, writes a report, and behaves sensibly when the folder does not exist or a file is unreadable. It will be split across modules, it will have a test, and you will be able to hand it to someone else.
Nothing new is introduced. The point is the assembly — seeing which parts of a real program are the interesting bit and which are the scaffolding that every program has.
$ python3 -m photo_tools.main photos --limit 50
Scanned 63 files in photos/
captioned 58
skipped 5 (3 unsupported, 2 unreadable)
Report written to photos/captions.jsonFour requirements, each of which forces a decision:
photo_tools/
├── __init__.py
├── captions.py turning a filename into a caption
├── scanning.py finding files and reading them
└── main.py arguments, orchestration, output
tests/
└── test_captions.pySplit by subject, as the modules lesson had it. Each file has one job and one direction of dependency.
scanning.py
Finds files on disk. Knows nothing about captions — it hands over paths and stops.
captions.py
Turns a path into a caption. Knows nothing about folders, so it can be tested with no files at all.
main.py
Knows about both, and is the only file that prints anything or reads command-line arguments.
That last point is a rule worth stating: the code that decides things should not be the code that prints things. A function that both computes and prints cannot be tested without capturing output, and cannot be reused by anything that wants the value.
captions.py is the actual subject matter, and it is the
smallest file:
Four things in eighteen lines, each from an earlier lesson.
A frozenset for the supported suffixes, because membership is
the only question ever asked of it. A custom exception, so
callers can catch this and not every ValueError in the call.
path.stem and path.suffix from pathlib, rather than
splitting on dots. And a comprehension inside join to
capitalise each word — .title() would have done it, but
.title() turns "o'brien" into "O'Brien", and it is worth
noticing that the obvious method has an opinion.
scanning.py deals with the filesystem, which is where things
go wrong:
Two decisions worth naming.
It raises rather than returns an empty list when the folder is wrong. An empty result and a missing folder are different situations, and collapsing them means the program reports "0 files" for a typo in a path.
It sorts the results. rglob returns entries in whatever
order the filesystem gives, which varies between machines. A
sort costs nothing here and means the same input produces the
same output — which is what makes the report diffable and the
test possible to write.
The yield makes this a generator, which you met in the
comprehensions lesson: files are handed over one at a time
rather than collected first, so a folder of a million files does
not have to fit in memory before work starts.
main.py is the only file that knows there is a user:
That function is the heart of the program and it prints one thing — a per-file warning, which genuinely belongs at the moment it happens. Everything else it returns, so the caller decides what to show.
The two except clauses are the exceptions lesson's rule in
practice: each one names a failure this code has a plan for.
Anything else — a bug in make_caption, a full disk — travels
up and stops the program with a traceback, which is correct.
raise SystemExit(1) is how a command-line program reports
failure. A non-zero exit code is what a shell script or a CI job
checks, and a program that prints "Error:" and exits zero
reports success to everything except a human reading the screen.
ensure_ascii=False keeps "Praça" as "Praça" in the JSON rather
than escaping it. With encoding="utf-8" on the write, that is
correct and much more readable.
Three tests, and notice what they test: the function that makes
a decision, not the one that walks the disk. make_caption
takes a value and returns a value, so testing it needs no
folder, no files and no setup — which is a direct payoff from
having kept it separate from everything that touches the
filesystem.
Run them with pytest. Testing gets a proper lesson in the
intermediate course; the point here is that code shaped like
this is easy to test, and code that mixes deciding with printing
and reading is not.
Count the lines.
Most of the program is reading input safely, handling what goes wrong, and reporting clearly.
That ratio is not a sign you did it wrong. It is what working software looks like, and it is the main thing that separates a program from a snippet. The snippet assumes the folder exists, the files are readable and nobody typo'd an argument. The program does not.
You have written a real program. It reads the world, survives what it finds, is split into parts that can be understood separately, and has tests for the part that makes decisions. That is a genuine milestone — most of what remains is depth rather than new kinds of thing.
Next is the course Python in Practice, which takes this
program and asks the questions a working codebase asks. How do
you install dependencies without breaking other projects? How do
you package this so someone can pip install it? What do type
hints buy you? How do you test the parts that do touch the
filesystem? What replaces print when the program runs
unattended?
Before you go, extend this tool. Add a --format option that
writes CSV instead of JSON. Add a summary of the commonest words
across all captions, using Counter. Make it skip files above a
size you pass in. Each of those is a small change to a program
you understand, which is the most efficient practice there is —
and the first time you extend your own code without breaking it
is when this stops feeling like exercises and starts feeling
like building things.
THE SHAPE OF A REAL PROGRAM
a module per subject captions / scanning / main
one entry point main.py, short
decisions and printing kept in different functions
raise on bad input do not return an empty result
catch what you planned for let the rest reach a traceback
exit non-zero on failure raise SystemExit(1)
a --dry-run for anything that writes
WHAT CAME FROM WHERE
frozenset, membership dictionaries and sets
custom exception exceptions
path.stem, .suffix, .rglob files
yield, one at a time comprehensions
argparse standard library
module per subject, __main__ modules
docstrings, one job each functions
THE PROPORTION
the subject matter is the smallest file
most of a program is input, failure and reporting
that is not a mistake - that is the difference between
a snippet and something you can hand to someone"""Turning image filenames into human-readable captions."""
SUPPORTED_SUFFIXES = frozenset({".jpg", ".jpeg", ".png", ".gif"})
class UnsupportedFormatError(Exception):
"""Raised for a file we do not produce captions for."""
def is_supported(path):
"""True if this looks like an image we can caption."""
return path.suffix.lower() in SUPPORTED_SUFFIXES
def make_caption(path, separator="_"):
"""Turn a filename into a caption.
'sunrise_over_lisbon.jpg' -> 'Sunrise Over Lisbon'
Raises UnsupportedFormatError for a file that is not an
image we recognise.
"""
if not is_supported(path):
raise UnsupportedFormatError(f"cannot caption {path.suffix!r}")
words = path.stem.replace(separator, " ").replace("-", " ")
return " ".join(word.capitalize() for word in words.split())"""Finding image files on disk."""
from pathlib import Path
def find_files(folder, limit=None):
"""Yield every file under folder, deepest included.
Raises NotADirectoryError if folder is not a directory.
"""
root = Path(folder)
if not root.is_dir():
raise NotADirectoryError(f"{folder} is not a directory")
found = 0
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
yield path
found += 1
if limit is not None and found >= limit:
return"""Command-line entry point for the photo tools."""
import argparse
import json
from pathlib import Path
from photo_tools.captions import UnsupportedFormatError, make_caption
from photo_tools.scanning import find_files
def build_report(folder, limit=None):
"""Caption every image under folder.
Returns (captions, unsupported, unreadable).
"""
captions = {}
unsupported = 0
unreadable = 0
for path in find_files(folder, limit=limit):
try:
captions[str(path)] = make_caption(path)
except UnsupportedFormatError:
unsupported += 1
except OSError as error:
print(f" could not read {path}: {error}")
unreadable += 1
return captions, unsupported, unreadabledef main():
parser = argparse.ArgumentParser(description="Caption photos.")
parser.add_argument("folder", help="folder to scan")
parser.add_argument("--limit", type=int, default=None,
help="stop after this many files")
parser.add_argument("--dry-run", action="store_true",
help="do not write the report")
args = parser.parse_args()
try:
captions, unsupported, unreadable = build_report(
args.folder, limit=args.limit
)
except NotADirectoryError as error:
print(f"Error: {error}")
raise SystemExit(1)
total = len(captions) + unsupported + unreadable
print(f"\nScanned {total} files in {args.folder}")
print(f" captioned {len(captions)}")
if unsupported or unreadable:
print(f" skipped {unsupported + unreadable} "
f"({unsupported} unsupported, {unreadable} unreadable)")
if args.dry_run:
print("\nDry run - nothing written.")
return
report = Path(args.folder) / "captions.json"
report.write_text(
json.dumps(captions, indent=2, ensure_ascii=False),
encoding="utf-8",
)
print(f"\nReport written to {report}")
if __name__ == "__main__":
main()"""Tests for caption generation."""
from pathlib import Path
import pytest
from photo_tools.captions import UnsupportedFormatError, make_caption
def test_underscores_become_words():
assert make_caption(Path("sunrise_over_lisbon.jpg")) == \
"Sunrise Over Lisbon"
def test_hyphens_work_too():
assert make_caption(Path("tram-28.png")) == "Tram 28"
def test_unsupported_format_is_rejected():
with pytest.raises(UnsupportedFormatError):
make_caption(Path("notes.txt"))