Python Course Part 10: The Capstone Project

This is the one we've been building toward. Across nine posts you've collected every piece you need; now we assemble them into a single tool you'd actually keep around.
We're building ai_tool — a command-line program that connects to an AI API and either summarizes a chunk of text or reviews a piece of code. Point it at a file, tell it what to do, and it streams back the answer.
Look at how much of the course this one project pulls in:
a virtual environment and a third-party SDK installed with
pip(Part 8)an API key kept safe in an environment variable (Part 8)
functions with single, clear jobs (Part 4)
try/exceptaround everything that can fail (Part 5)pathlibfor reading the input file (Part 5)type hints so the code documents itself (Part 9)
a
MODESdictionary driving behaviour (Part 3)plus one new standard-library tool:
argparse, for parsing command-line arguments
Here's the target:
$ python ai_tool.py summarize article.txt
--- Summarizing article.txt ---
TL;DR: Small, well-tested scripts beat large untested ones every time.
- Automated tests catch regressions a manual eyeball check would miss.
- Pure functions are far easier to test than code tangled up with I/O.
- Type hints plus mypy surface mistakes before the program ever runs.
$ python ai_tool.py review calculator.py
--- Reviewing calculator.py ---
Solid for its size. Two things worth fixing:
1. (High) float(input(...)) crashes on non-numeric input. Wrap each input in
try/except and re-prompt, or the whole script dies on a typo.
2. (Low) The receipt math is fine, but pull the tip rate into a named constant
so the magic 100 isn't buried in the expression.
1. The Shape of a CLI Tool: argparse
A good command-line tool reads its instructions from the command line, not from hardcoded values. Python's standard library ships argparse for exactly this — it parses arguments, validates them, and generates a --help screen for free.
import argparse
parser = argparse.ArgumentParser(description="Summarize text or review code with AI.")
parser.add_argument("mode", choices=["summarize", "review"], help="What to do.")
parser.add_argument("file", help="Path to the input file, or - for stdin.")
args = parser.parse_args()
print(args.mode, args.file)
Run python ai_tool.py --help and argparse prints usage automatically. Pass an invalid mode and it rejects it with a clear message — that's the choices= constraint doing the validating for you, the same way argparse is doing the work you'd otherwise hand-roll.
2. Reading the Input
We want to accept either a file path or piped input (so cat notes.txt | python ai_tool.py summarize - works). One small, typed function handles both, using pathlib from Part 5:
import sys
from pathlib import Path
def read_input(source: str) -> str:
"""Read text from a file path, or from stdin if source is '-'."""
if source == "-":
return sys.stdin.read()
path = Path(source)
if not path.is_file():
raise FileNotFoundError(f"No such file: {source}")
return path.read_text(encoding="utf-8")
It returns a string or raises FileNotFoundError — and because it's a small pure-ish function, you could test it exactly like the ones in Part 9.
3. Installing the SDK and Wiring Up the Key
We'll use the official Anthropic SDK to talk to Claude. With your virtual environment active (Part 8):
pip install anthropic
pip freeze > requirements.txt
You'll need an API key. Create one at console.anthropic.com, then put it in your environment — never in the code (Part 8's golden rule):
# macOS / Linux:
export ANTHROPIC_API_KEY="sk-ant-..."
# Windows (PowerShell):
$env:ANTHROPIC_API_KEY = "sk-ant-..."
The SDK reads that variable automatically. Creating a client is a single line:
import anthropic
client = anthropic.Anthropic() # picks up ANTHROPIC_API_KEY from the environment
4. Calling the Model
A request to the API is one method call. You give it three things: which model to use, the conversation (a list of messages), and an optional system prompt that sets the model's role and behaviour.
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024, # the maximum length of the reply, in tokens
system="You are a precise summarizer. Be concise and faithful to the source.",
messages=[
{"role": "user", "content": "Summarize this: ..."}
],
)
# response.content is a list of blocks; pull the text out of the text block:
text = next(block.text for block in response.content if block.type == "text")
print(text)
🛑 Dev Callout: The Model Is a (Mostly) Stateless Function
Think of the API like a function call, not a chat that remembers you. Each request is completely independent — the model retains nothing from previous calls. If you want a follow-up to have context, you resend the whole conversation in
messages. That's why this fits so naturally at the end of a programming course: it behaves like a function — input (your prompt) goes in, output comes back — with two twists worth knowing. First, unlike the pure functions of Part 9, the same prompt can produce slightly different wording each time. Second, you pay per token (roughly, per chunk of text) both ways, so the size of what you send and receive isn't free. Thesystemprompt is your configuration knob: you change the tool's behaviour by editing a string, not by rewriting logic.
5. Streaming for a Better Feel
For a CLI, waiting in silence while the whole answer generates feels broken. Streaming prints the reply token by token as it arrives — the typewriter effect you've seen in every AI chat app. The SDK makes it a context manager (just like the file handling in Part 5):
with client.messages.stream(
model="claude-opus-4-8",
max_tokens=1024,
system="You are a precise summarizer.",
messages=[{"role": "user", "content": text}],
) as stream:
for chunk in stream.text_stream:
print(chunk, end="", flush=True) # flush=True forces each chunk out immediately
That's the whole difference: iterate stream.text_stream and print each piece as it lands.
6. Bringing It All Together
Here's the complete tool. Read it top to bottom — you'll recognize every technique from the previous nine parts. Save it as ai_tool.py:
import argparse
import os
import sys
from pathlib import Path
import anthropic
MODEL = "claude-opus-4-8"
# A dictionary maps each mode to its system prompt and reply-length cap.
# Adding a third mode later is just a new entry here — no logic to touch.
MODES = {
"summarize": {
"system": (
"You are a precise summarizer. Given the user's text, produce a "
"one-sentence TL;DR, then 3-5 bullet points covering the key ideas. "
"Use only information present in the text; never invent details."
),
"max_tokens": 1024,
},
"review": {
"system": (
"You are an experienced, constructive code reviewer. Review the code "
"the user provides. List concrete issues ordered by severity — bugs and "
"security problems first, then clarity and style. Reference specific "
"lines or names. If the code is solid, say so plainly."
),
"max_tokens": 4096,
},
}
def read_input(source: str) -> str:
"""Read text from a file path, or from stdin if source is '-'."""
if source == "-":
return sys.stdin.read()
path = Path(source)
if not path.is_file():
raise FileNotFoundError(f"No such file: {source}")
return path.read_text(encoding="utf-8")
def run(mode: str, text: str) -> None:
"""Send the text to Claude in the given mode and stream the reply."""
config = MODES[mode]
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
with client.messages.stream(
model=MODEL,
max_tokens=config["max_tokens"],
system=config["system"],
messages=[{"role": "user", "content": text}],
) as stream:
for chunk in stream.text_stream:
print(chunk, end="", flush=True)
print() # newline after the streamed output
def main() -> None:
parser = argparse.ArgumentParser(
description="Summarize text or review code using Claude."
)
parser.add_argument("mode", choices=MODES.keys(), help="What to do with the input.")
parser.add_argument("file", help="Path to the input file, or - to read from stdin.")
args = parser.parse_args()
# Fail early and clearly if the key isn't set (Part 8).
if not os.environ.get("ANTHROPIC_API_KEY"):
print("[ERROR] ANTHROPIC_API_KEY is not set.", file=sys.stderr)
print(" Get a key at https://console.anthropic.com, then run:", file=sys.stderr)
print(' export ANTHROPIC_API_KEY="sk-ant-..."', file=sys.stderr)
sys.exit(1)
# Read the input, handling a missing file gracefully (Part 5).
try:
text = read_input(args.file)
except (FileNotFoundError, OSError) as e:
print(f"[ERROR] {e}", file=sys.stderr)
sys.exit(1)
if not text.strip():
print("[ERROR] The input is empty — nothing to do.", file=sys.stderr)
sys.exit(1)
label = "stdin" if args.file == "-" else args.file
banner = "Summarizing" if args.mode == "summarize" else "Reviewing"
print(f"--- {banner} {label} ---\n")
# Catch the SDK's typed exceptions, most specific first (Parts 5 & 9).
try:
run(args.mode, text)
except anthropic.AuthenticationError:
print("\n[ERROR] Invalid API key — check ANTHROPIC_API_KEY.", file=sys.stderr)
sys.exit(1)
except anthropic.RateLimitError:
print("\n[ERROR] Rate limited. Wait a moment and try again.", file=sys.stderr)
sys.exit(1)
except anthropic.APIError as e:
print(f"\n[ERROR] The API call failed: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
Take it for a spin:
# Summarize an article
python ai_tool.py summarize article.txt
# Review one of your earlier scripts from this course
python ai_tool.py review calculator.py
# Summarize piped input
cat notes.txt | python ai_tool.py summarize -
You just built a working AI tool. Sit with that for a second — ten posts ago, input() returning a string was a surprise.
7. Make It Yours
The architecture is deliberately easy to extend. A few directions, in rough order of effort:
Add a mode. Want a "translate" or "explain-like-I'm-five" mode? Add one entry to the
MODESdictionary with its own system prompt. No other code changes — that's the payoff of driving behaviour from data (Part 3) instead of branching logic.Add a flag. Use
argparseto add--modelso you can pick a faster, cheaper model for quick jobs, or--max-tokensto cap the reply length.Save the output. Write the result to a file with
pathlib(Part 5) when a--out report.mdflag is given.Test the pure parts.
read_inputand theMODESlookup are testable without ever calling the API — writepytestcases for them (Part 9), keeping the network call isolated inrun().
The End of the Beginning
That's the Pragmatic Python Bootcamp. You started by installing an interpreter and printing a receipt; you're finishing with a typed, tested, error-handled CLI that streams live AI responses from a real API.
More importantly, you've built the instincts: cast your inputs, let the data structure do the work, return instead of print, catch only what you expect, isolate the messy I/O from the logic you can test, and keep your secrets out of your source. Those habits outlast any single library.
Where to go from here depends on what you want to build. Web backends (FastAPI, Django), data work (pandas, polars), automation and scripting, or deeper into AI tooling and agents — the language under all of them is the one you now know. Pick a project you actually care about and build it. That's how the rest of it sticks.
Thanks for coding along. Now go make something.