7. Mini project: Image downloader
Time to put the whole chapter together. We'll build a real image downloader that uses every technique you've just learned, the same patterns you'd find in code that syncs profile pictures, pulls email attachments, or manages a media library.
The program applies the Make → Check → Extract pattern to binary data, validates content types from headers before writing anything to disk, handles failure modes without crashing, and returns structured results the caller can act on. Treat it as a template, not just an exercise. You'll reach for this shape any time you need to download a file.
Building the downloader
The features we want, in one list:
- The pattern. Strict Make → Check → Extract.
- Validation.
response.okplus aContent-Typecheck. - Safety. Network calls wrapped in
try/except. - Binary handling.
response.contentsaved with"wb". - Predictable returns. A tuple of
(success, message, filename)instead of raising unhandled exceptions.
The return contract deserves a note up front. The function returns (success_boolean, status_message, filename), which lets the caller decide what to do on failure without needing its own try/except around every call. Save this at the project root as downloader.py:
"""
Professional Image Downloader
Demonstrates all Chapter 4 concepts in one practical application.
"""
import requests
def download_image(url, filename=None):
"""
Download an image with professional error handling and validation.
Args:
url: URL of the image to download
filename: Optional filename; if None, will be auto-generated
Returns:
Tuple of (success: bool, message: str, filename: str or None)
"""
try:
# STEP 1: MAKE the request
print(f"Downloading from {url}...")
response = requests.get(url, timeout=10)
# STEP 2: CHECK the response
# Check status code
if not response.ok:
return (
False,
f"Request failed: {response.status_code} {response.reason}",
None
)
# Verify content type using headers
content_type = response.headers.get("Content-Type", "")
if not content_type.startswith("image/"):
return (
False,
f"Expected image but received {content_type}",
None
)
# STEP 3: EXTRACT and save the data
# Determine file extension from Content-Type
if filename is None:
extension_map = {
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
}
extension = extension_map.get(content_type, "bin")
filename = f"downloaded_image.{extension}"
# Save binary data
try:
with open(filename, "wb") as f:
f.write(response.content)
except IOError as e:
return (False, f"Could not save file: {e}", None)
# Calculate file size for feedback
size_kb = len(response.content) / 1024
return (
True,
f"Successfully downloaded {size_kb:.1f} KB",
filename
)
except requests.exceptions.Timeout:
return (False, "Request timed out", None)
except requests.exceptions.ConnectionError:
return (False, "Could not connect to server", None)
except requests.exceptions.RequestException as e:
return (False, f"Request failed: {e}", None)
def main():
"""Test the downloader with various scenarios."""
print("IMAGE DOWNLOADER TEST SUITE")
print("=" * 60)
test_cases = [
{
"url": "https://httpbin.org/image/png",
"description": "Valid PNG image",
"filename": "test_image.png"
},
{
"url": "https://httpbin.org/image/jpeg",
"description": "Valid JPEG image",
"filename": "test_image.jpg"
},
{
"url": "https://httpbin.org/status/404",
"description": "404 Not Found error",
"filename": "should_fail.png"
},
{
"url": "https://httpbin.org/json",
"description": "JSON instead of image",
"filename": "should_fail.png"
},
]
for i, test in enumerate(test_cases, 1):
print(f"\nTest {i}: {test['description']}")
print("-" * 60)
success, message, saved_filename = download_image(
test["url"],
test.get("filename")
)
if success:
print(f"✅ SUCCESS: {message}")
print(f" Saved as: {saved_filename}")
else:
print(f"❌ FAILED: {message}")
print("\n" + "=" * 60)
print("Test suite complete. Check your project folder for downloaded images.")
if __name__ == "__main__":
main()
Run it from the project root with python downloader.py. The exact file sizes can drift if httpbin changes the sample images, but the success and failure paths should match this shape:
IMAGE DOWNLOADER TEST SUITE
============================================================
Test 1: Valid PNG image
------------------------------------------------------------
Downloading from https://httpbin.org/image/png...
✅ SUCCESS: Successfully downloaded 7.9 KB
Saved as: test_image.png
Test 2: Valid JPEG image
------------------------------------------------------------
Downloading from https://httpbin.org/image/jpeg...
✅ SUCCESS: Successfully downloaded 34.8 KB
Saved as: test_image.jpg
Test 3: 404 Not Found error
------------------------------------------------------------
Downloading from https://httpbin.org/status/404...
❌ FAILED: Request failed: 404 NOT FOUND
Test 4: JSON instead of image
------------------------------------------------------------
Downloading from https://httpbin.org/json...
❌ FAILED: Expected image but received application/json
============================================================
Test suite complete. Check your project folder for downloaded images.
A detail worth calling out in the code: the content-type check uses .startswith("image/"), not an equality check. Real-world headers love to add parameters, so a perfectly valid response might arrive as image/png; charset=utf-8 and fail a strict == "image/png" match. Some storage APIs take it further and return the generic application/octet-stream even for images, in which case you can't rely on the header alone and have to fall back on the URL extension or the file's magic bytes. startswith covers the common cases cleanly.
Everything in this single file is a direct application of chapter material: the three-step pattern, the response.ok check, the Content-Type analysis for both validation and extension selection, binary handling with response.content and "wb", a try/except that covers network, status, content-type, and disk-I/O failure modes, and a predictable return shape that gives callers everything they need.
Why the tuple return is worth it
download_image() returns a three-tuple of (success, message, filename). That shape shows up all over professional Python. Save this at the project root as use_downloader.py, next to downloader.py, to see what using it looks like in practice:
from downloader import download_image
url = "https://httpbin.org/image/png"
# Omit the filename so the downloader names it from the Content-Type
success, message, filename = download_image(url)
if success:
print(f"Saved to {filename}: {message}")
# Continue processing the downloaded file
else:
print(f"Download failed: {message}")
# Handle the error appropriately
That shape gives the caller every piece of information they need, did it work, what happened, and where's the file, without forcing them to wrap every call in their own try/except. It's also more flexible than raising an exception because the caller gets to decide how to respond to failure.
A brief aside: the Content-Disposition header
Our downloader auto-generates filenames like downloaded_image.png. In the real world, many servers suggest a specific filename via a header that looks like this:
Content-Disposition: attachment; filename="vacation_photo_2024.jpg"
Extracting that filename cleanly needs you to parse the header string, usually via Python's email module, which is more code than it looks. A production downloader typically tries a small hierarchy: check Content-Disposition first, fall back to the final segment of the URL path if that's a real filename, and only then auto-generate from the content type the way we do. Worth knowing that the option exists, even if we don't use it here.
Your turn: extend the downloader
A few directions to take this further before we wrap up the chapter:
- Add a size limit. Reject files larger than 5 MB by reading the
Content-Lengthheader before writing. Hint:int(response.headers.get("Content-Length", 0)). - Add progress feedback. Use
response.iter_content(chunk_size=8192)to stream the body in chunks and print a running percentage for long downloads. - Support more file types. Extend the content-type check to accept
application/pdfandtext/plain, and pick the right extension for each. - Batch downloads. Write a function that takes a list of URLs and returns a list of results. How do you make sure one failure doesn't stop the rest?
Try each one for at least fifteen minutes before looking at solutions. The struggle is where the chapter actually sinks in; if you get stuck, the patterns you need are all above. The next page recaps what you've built and looks forward to Part II.