From a65d933eec453839f13edbb5b78de8e3381778a2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 14 Jan 2026 16:39:02 +0000
Subject: [PATCH] Add HTTP status code Anki deck generator with cat images
Co-authored-by: kuhyx <147418882+kuhyx@users.noreply.github.com>
---
.gitignore | 3 +
.../download_cats/README_HTTP_STATUS.md | 109 +++++
python_pkg/download_cats/http_status_anki.py | 425 ++++++++++++++++++
.../tests/test_http_status_anki.py | 375 ++++++++++++++++
4 files changed, 912 insertions(+)
create mode 100644 python_pkg/download_cats/README_HTTP_STATUS.md
create mode 100755 python_pkg/download_cats/http_status_anki.py
create mode 100644 python_pkg/download_cats/tests/test_http_status_anki.py
diff --git a/.gitignore b/.gitignore
index 1933b9a..48cc27b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -268,5 +268,8 @@ python_pkg/geo_cache/
python_pkg/*/.venv/
python_pkg/*/cache/
+# HTTP cat image cache
+python_pkg/download_cats/http_cat_cache/
+
# Large geojson files that can be downloaded
python_pkg/warsaw_districts/warszawa-dzielnice.geojson
diff --git a/python_pkg/download_cats/README_HTTP_STATUS.md b/python_pkg/download_cats/README_HTTP_STATUS.md
new file mode 100644
index 0000000..6cef3dc
--- /dev/null
+++ b/python_pkg/download_cats/README_HTTP_STATUS.md
@@ -0,0 +1,109 @@
+# HTTP Status Code Anki Deck Generator
+
+Generate Anki flashcards for HTTP status codes with cat images from [http.cat](https://http.cat).
+
+## Features
+
+- 📚 Comprehensive coverage of HTTP status codes (1xx - 5xx)
+- 🐱 Fun cat images for each status code
+- 🔄 Bidirectional flashcards for better memorization:
+ - Code → Description + Image
+ - Description + Image → Code
+- 💾 Smart caching to avoid re-downloading images
+- 🎨 Dark mode support in Anki
+
+## Installation
+
+Dependencies are already included in the main `requirements.txt`:
+- `requests` - For downloading images
+- `genanki` - For creating Anki packages
+
+## Usage
+
+### Basic Usage
+
+Generate an Anki deck with default settings:
+
+```bash
+python python_pkg/download_cats/http_status_anki.py
+```
+
+This creates `http_status_codes.apkg` in the current directory.
+
+### Custom Output
+
+Specify a custom output file:
+
+```bash
+python python_pkg/download_cats/http_status_anki.py --output my_deck.apkg
+```
+
+### Custom Deck Name
+
+Set a custom name for the Anki deck:
+
+```bash
+python python_pkg/download_cats/http_status_anki.py --deck-name "My HTTP Status Cards"
+```
+
+### Force Re-download
+
+Download images even if cached versions exist:
+
+```bash
+python python_pkg/download_cats/http_status_anki.py --no-cache
+```
+
+### Verbose Logging
+
+Enable detailed logging:
+
+```bash
+python python_pkg/download_cats/http_status_anki.py --verbose
+```
+
+## How It Works
+
+1. **Downloads Images**: Fetches cat images from https://http.cat/[status_code].jpg
+2. **Caches Locally**: Saves images to `python_pkg/download_cats/http_cat_cache/` to avoid re-downloading
+3. **Creates Bidirectional Cards**:
+ - **Front**: Status code (e.g., "200")
+ - **Back**: Description ("OK") + Cat image
+ - **Reverse**: Description + Image → Status code
+4. **Exports to Anki**: Creates a `.apkg` file that can be imported into Anki
+
+## Supported Status Codes
+
+The script includes 79 HTTP status codes across all categories:
+
+- **1xx Informational**: 100, 101, 102, 103
+- **2xx Success**: 200, 201, 202, 203, 204, 205, 206, 207, 208, 226
+- **3xx Redirection**: 300, 301, 302, 303, 304, 305, 307, 308
+- **4xx Client Error**: 400-499 (including fun ones like 418 "I'm a teapot")
+- **5xx Server Error**: 500-599 (including various server/proxy errors)
+
+## Importing into Anki
+
+1. Run the script to generate the `.apkg` file
+2. Open Anki
+3. Click "Import File"
+4. Select the generated `.apkg` file
+5. Start studying!
+
+## Cache Location
+
+Images are cached at: `python_pkg/download_cats/http_cat_cache/`
+
+This directory is automatically created and is ignored by git.
+
+## Testing
+
+Run the comprehensive test suite:
+
+```bash
+python -m pytest python_pkg/download_cats/tests/test_http_status_anki.py -v
+```
+
+## License
+
+Same as the parent repository.
diff --git a/python_pkg/download_cats/http_status_anki.py b/python_pkg/download_cats/http_status_anki.py
new file mode 100755
index 0000000..11e6f14
--- /dev/null
+++ b/python_pkg/download_cats/http_status_anki.py
@@ -0,0 +1,425 @@
+#!/usr/bin/env python3
+"""Anki flashcard generator for HTTP status codes with cat images.
+
+Downloads cat images from https://http.cat/ for each HTTP status code
+and creates an Anki deck with bidirectional flashcards for memorization.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import logging
+from pathlib import Path
+import sys
+from typing import TYPE_CHECKING
+
+import genanki
+import requests
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+_logger = logging.getLogger(__name__)
+
+# Constants
+REQUEST_TIMEOUT = 30 # seconds
+CACHE_DIR = Path(__file__).parent / "http_cat_cache"
+
+# Comprehensive HTTP status codes available on http.cat
+# Data from: https://http.cat/
+HTTP_STATUS_CODES = {
+ # 1xx Informational
+ 100: "Continue",
+ 101: "Switching Protocols",
+ 102: "Processing",
+ 103: "Early Hints",
+ # 2xx Success
+ 200: "OK",
+ 201: "Created",
+ 202: "Accepted",
+ 203: "Non-Authoritative Information",
+ 204: "No Content",
+ 205: "Reset Content",
+ 206: "Partial Content",
+ 207: "Multi-Status",
+ 208: "Already Reported",
+ 226: "IM Used",
+ # 3xx Redirection
+ 300: "Multiple Choices",
+ 301: "Moved Permanently",
+ 302: "Found",
+ 303: "See Other",
+ 304: "Not Modified",
+ 305: "Use Proxy",
+ 307: "Temporary Redirect",
+ 308: "Permanent Redirect",
+ # 4xx Client Error
+ 400: "Bad Request",
+ 401: "Unauthorized",
+ 402: "Payment Required",
+ 403: "Forbidden",
+ 404: "Not Found",
+ 405: "Method Not Allowed",
+ 406: "Not Acceptable",
+ 407: "Proxy Authentication Required",
+ 408: "Request Timeout",
+ 409: "Conflict",
+ 410: "Gone",
+ 411: "Length Required",
+ 412: "Precondition Failed",
+ 413: "Payload Too Large",
+ 414: "URI Too Long",
+ 415: "Unsupported Media Type",
+ 416: "Range Not Satisfiable",
+ 417: "Expectation Failed",
+ 418: "I'm a teapot",
+ 420: "Enhance Your Calm",
+ 421: "Misdirected Request",
+ 422: "Unprocessable Entity",
+ 423: "Locked",
+ 424: "Failed Dependency",
+ 425: "Too Early",
+ 426: "Upgrade Required",
+ 428: "Precondition Required",
+ 429: "Too Many Requests",
+ 431: "Request Header Fields Too Large",
+ 444: "No Response",
+ 450: "Blocked by Windows Parental Controls",
+ 451: "Unavailable For Legal Reasons",
+ 497: "HTTP Request Sent to HTTPS Port",
+ 498: "Token Expired/Invalid",
+ 499: "Client Closed Request",
+ # 5xx Server Error
+ 500: "Internal Server Error",
+ 501: "Not Implemented",
+ 502: "Bad Gateway",
+ 503: "Service Unavailable",
+ 504: "Gateway Timeout",
+ 505: "HTTP Version Not Supported",
+ 506: "Variant Also Negotiates",
+ 507: "Insufficient Storage",
+ 508: "Loop Detected",
+ 509: "Bandwidth Limit Exceeded",
+ 510: "Not Extended",
+ 511: "Network Authentication Required",
+ 521: "Web Server Is Down",
+ 522: "Connection Timed Out",
+ 523: "Origin Is Unreachable",
+ 524: "A Timeout Occurred",
+ 525: "SSL Handshake Failed",
+ 526: "Invalid SSL Certificate",
+ 527: "Railgun Error",
+ 529: "Site is overloaded",
+ 530: "Site is frozen",
+ 599: "Network Connect Timeout Error",
+}
+
+
+def _download_cat_image(status_code: int) -> bytes:
+ """Download a cat image for the given HTTP status code.
+
+ Args:
+ status_code: HTTP status code to download image for.
+
+ Returns:
+ Image bytes.
+
+ Raises:
+ requests.exceptions.RequestException: If download fails.
+ """
+ url = f"https://http.cat/{status_code}.jpg"
+ _logger.info("Downloading %s", url)
+ response = requests.get(url, timeout=REQUEST_TIMEOUT)
+ response.raise_for_status()
+ return response.content
+
+
+def _get_cached_image_path(status_code: int) -> Path:
+ """Get the cache file path for a status code image.
+
+ Args:
+ status_code: HTTP status code.
+
+ Returns:
+ Path to cached image file.
+ """
+ return CACHE_DIR / f"{status_code}.jpg"
+
+
+def get_or_download_image(status_code: int, *, use_cache: bool = True) -> bytes:
+ """Get cat image for status code, using cache if available.
+
+ Args:
+ status_code: HTTP status code.
+ use_cache: Whether to use cached images if available.
+
+ Returns:
+ Image bytes.
+
+ Raises:
+ requests.exceptions.RequestException: If download fails.
+ """
+ cache_path = _get_cached_image_path(status_code)
+
+ # Check cache first
+ if use_cache and cache_path.exists():
+ _logger.info("Using cached image for %d", status_code)
+ return cache_path.read_bytes()
+
+ # Download and cache
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
+ image_data = _download_cat_image(status_code)
+ cache_path.write_bytes(image_data)
+ _logger.info("Cached image for %d at %s", status_code, cache_path)
+
+ return image_data
+
+
+def generate_anki_package(
+ status_codes: dict[int, str],
+ deck_name: str = "HTTP Status Codes",
+ *,
+ use_cache: bool = True,
+) -> genanki.Package:
+ """Generate Anki package for HTTP status codes with cat images.
+
+ Creates bidirectional flashcards:
+ - Code -> Image + Description
+ - Description -> Code
+
+ Args:
+ status_codes: Dictionary mapping status codes to descriptions.
+ deck_name: Name for the Anki deck.
+ use_cache: Whether to use cached images.
+
+ Returns:
+ Generated Anki package.
+ """
+ # Generate stable model IDs from deck name
+ model_id_hash = hashlib.md5(
+ f"http_status_{deck_name}".encode(), usedforsecurity=False
+ )
+ model_id_code_to_desc = int(model_id_hash.hexdigest()[:8], 16)
+
+ # Different model ID for reverse direction
+ reverse_hash = hashlib.md5(
+ f"http_status_reverse_{deck_name}".encode(), usedforsecurity=False
+ )
+ model_id_desc_to_code = int(reverse_hash.hexdigest()[:8], 16)
+
+ card_css = """
+.card {
+ font-family: Arial, sans-serif;
+ font-size: 24px;
+ text-align: center;
+ color: #333;
+ background-color: #fff;
+}
+.card.night_mode {
+ color: #eee;
+ background-color: #2f2f2f;
+}
+.status-code {
+ font-size: 48px;
+ font-weight: bold;
+ margin: 20px;
+ color: #2C3E50;
+}
+.card.night_mode .status-code {
+ color: #ECF0F1;
+}
+.description {
+ font-size: 32px;
+ margin: 20px;
+ color: #34495E;
+}
+.card.night_mode .description {
+ color: #BDC3C7;
+}
+.image-container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 60vh;
+ margin: 20px;
+}
+.image-container img {
+ max-width: 90%;
+ max-height: 60vh;
+ object-fit: contain;
+ border-radius: 10px;
+}
+"""
+
+ # Model 1: Status Code -> Image + Description
+ model_code_to_desc = genanki.Model(
+ model_id_code_to_desc,
+ "HTTP Status Code to Description",
+ fields=[
+ {"name": "StatusCode"},
+ {"name": "Description"},
+ {"name": "Image"},
+ ],
+ templates=[
+ {
+ "name": "Code to Description",
+ "qfmt": '
{{StatusCode}}
',
+ "afmt": '{{StatusCode}}
'
+ '
'
+ '{{Description}}
'
+ '{{Image}}
',
+ },
+ ],
+ css=card_css,
+ )
+
+ # Model 2: Description -> Status Code
+ model_desc_to_code = genanki.Model(
+ model_id_desc_to_code,
+ "HTTP Status Description to Code",
+ fields=[
+ {"name": "StatusCode"},
+ {"name": "Description"},
+ {"name": "Image"},
+ ],
+ templates=[
+ {
+ "name": "Description to Code",
+ "qfmt": '{{Description}}
'
+ '{{Image}}
',
+ "afmt": '{{Description}}
'
+ '{{Image}}
'
+ '
'
+ '{{StatusCode}}
',
+ },
+ ],
+ css=card_css,
+ )
+
+ # Use MD5 hash of deck name for stable deck ID
+ deck_id_hash = hashlib.md5(deck_name.encode(), usedforsecurity=False)
+ deck_id = int(deck_id_hash.hexdigest()[:8], 16)
+
+ my_deck = genanki.Deck(deck_id, deck_name)
+ media_files = []
+
+ for status_code, description in status_codes.items():
+ try:
+ image_data = get_or_download_image(status_code, use_cache=use_cache)
+ filename = f"http_cat_{status_code}.jpg"
+
+ # Save to temp directory for genanki
+ temp_path = Path(f"/tmp/{filename}") # noqa: S108
+ temp_path.write_bytes(image_data)
+ media_files.append(str(temp_path))
+
+ image_html = f'
'
+
+ # Add card: Code -> Description + Image
+ note_code_to_desc = genanki.Note(
+ model=model_code_to_desc,
+ fields=[str(status_code), description, image_html],
+ tags=["http", "status-codes", "programming"],
+ )
+ my_deck.add_note(note_code_to_desc)
+
+ # Add card: Description + Image -> Code
+ note_desc_to_code = genanki.Note(
+ model=model_desc_to_code,
+ fields=[str(status_code), description, image_html],
+ tags=["http", "status-codes", "programming"],
+ )
+ my_deck.add_note(note_desc_to_code)
+
+ _logger.info("Added cards for status code %d", status_code)
+
+ except requests.exceptions.RequestException:
+ _logger.exception(
+ "Failed to download image for status code %d", status_code
+ )
+
+ package = genanki.Package(my_deck)
+ package.media_files = media_files
+ return package
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """Main entry point.
+
+ Args:
+ argv: Command-line arguments.
+
+ Returns:
+ Exit code.
+ """
+ parser = argparse.ArgumentParser(
+ description="Generate Anki flashcards for HTTP status codes with cat images.",
+ )
+ parser.add_argument(
+ "--output",
+ "-o",
+ type=str,
+ default=None,
+ help="Output file path (default: http_status_codes.apkg)",
+ )
+ parser.add_argument(
+ "--deck-name",
+ "-d",
+ type=str,
+ default="HTTP Status Codes",
+ help="Name for the Anki deck",
+ )
+ parser.add_argument(
+ "--no-cache",
+ action="store_true",
+ help="Download images even if cached versions exist",
+ )
+ parser.add_argument(
+ "--verbose",
+ "-v",
+ action="store_true",
+ help="Enable verbose logging",
+ )
+
+ args = parser.parse_args(argv)
+ output_path = Path(args.output) if args.output else Path("http_status_codes.apkg")
+
+ # Configure logging
+ logging.basicConfig(
+ level=logging.INFO if args.verbose else logging.WARNING,
+ format="%(levelname)s: %(message)s",
+ )
+
+ try:
+ sys.stdout.write("Generating HTTP status code flashcards...\n")
+ sys.stdout.write(f"Total status codes: {len(HTTP_STATUS_CODES)}\n")
+ sys.stdout.write(f"Cache directory: {CACHE_DIR}\n")
+ sys.stdout.write(f"Using cache: {not args.no_cache}\n\n")
+
+ package = generate_anki_package(
+ HTTP_STATUS_CODES,
+ args.deck_name,
+ use_cache=not args.no_cache,
+ )
+ package.write_to_file(str(output_path))
+
+ sys.stdout.write("\n")
+ sys.stdout.write("=" * 60 + "\n")
+ sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
+ sys.stdout.write("=" * 60 + "\n")
+ sys.stdout.write(f"Total cards: {len(HTTP_STATUS_CODES) * 2} ")
+ sys.stdout.write("(bidirectional)\n")
+ sys.stdout.write(f"Output file: {output_path.absolute()}\n")
+ sys.stdout.write(f"Cache location: {CACHE_DIR.absolute()}\n")
+ sys.stdout.write("\nImport the .apkg file into Anki to start learning!\n")
+
+ except (OSError, ValueError, RuntimeError) as e:
+ sys.stderr.write(f"Error: {e}\n")
+ return 1
+ else:
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/python_pkg/download_cats/tests/test_http_status_anki.py b/python_pkg/download_cats/tests/test_http_status_anki.py
new file mode 100644
index 0000000..620d8f2
--- /dev/null
+++ b/python_pkg/download_cats/tests/test_http_status_anki.py
@@ -0,0 +1,375 @@
+"""Unit tests for HTTP status code Anki generator."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+import requests
+
+from python_pkg.download_cats.http_status_anki import (
+ CACHE_DIR,
+ HTTP_STATUS_CODES,
+ REQUEST_TIMEOUT,
+ _download_cat_image,
+ _get_cached_image_path,
+ generate_anki_package,
+ get_or_download_image,
+ main,
+)
+
+
+class TestDownloadCatImage:
+ """Tests for _download_cat_image function."""
+
+ def test_successful_download(self) -> None:
+ """Test successful image download."""
+ status_code = 200
+ image_content = b"fake cat image"
+
+ mock_response = MagicMock()
+ mock_response.content = image_content
+
+ with patch("requests.get", return_value=mock_response) as mock_get:
+ result = _download_cat_image(status_code)
+
+ mock_get.assert_called_once_with(
+ "https://http.cat/200.jpg",
+ timeout=REQUEST_TIMEOUT,
+ )
+ mock_response.raise_for_status.assert_called_once()
+ assert result == image_content
+
+ def test_http_error_raised(self) -> None:
+ """Test that HTTP errors are raised."""
+ mock_response = MagicMock()
+ mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
+ "404 Not Found"
+ )
+
+ with (
+ patch("requests.get", return_value=mock_response),
+ pytest.raises(requests.exceptions.HTTPError),
+ ):
+ _download_cat_image(404)
+
+ def test_connection_error_raised(self) -> None:
+ """Test that connection errors are raised."""
+ with (
+ patch(
+ "requests.get",
+ side_effect=requests.exceptions.ConnectionError("Network error"),
+ ),
+ pytest.raises(requests.exceptions.ConnectionError),
+ ):
+ _download_cat_image(500)
+
+
+class TestGetCachedImagePath:
+ """Tests for _get_cached_image_path function."""
+
+ def test_returns_correct_path(self) -> None:
+ """Test that correct cache path is returned."""
+ status_code = 200
+ expected_path = CACHE_DIR / "200.jpg"
+
+ result = _get_cached_image_path(status_code)
+
+ assert result == expected_path
+
+ def test_different_codes_different_paths(self) -> None:
+ """Test that different status codes get different paths."""
+ path_200 = _get_cached_image_path(200)
+ path_404 = _get_cached_image_path(404)
+
+ assert path_200 != path_404
+ assert "200.jpg" in str(path_200)
+ assert "404.jpg" in str(path_404)
+
+
+class TestGetOrDownloadImage:
+ """Tests for get_or_download_image function."""
+
+ def test_uses_cache_when_available(self) -> None:
+ """Test that cached image is used when available."""
+ status_code = 200
+ cached_content = b"cached image"
+
+ with (
+ patch(
+ "pathlib.Path.exists",
+ return_value=True,
+ ),
+ patch(
+ "pathlib.Path.read_bytes",
+ return_value=cached_content,
+ ),
+ patch(
+ "python_pkg.download_cats.http_status_anki._download_cat_image"
+ ) as mock_download,
+ ):
+ result = get_or_download_image(status_code, use_cache=True)
+
+ assert result == cached_content
+ mock_download.assert_not_called()
+
+ def test_downloads_when_not_cached(self) -> None:
+ """Test that image is downloaded when not in cache."""
+ status_code = 404
+ downloaded_content = b"downloaded image"
+
+ with (
+ patch("pathlib.Path.exists", return_value=False),
+ patch("pathlib.Path.mkdir"),
+ patch(
+ "python_pkg.download_cats.http_status_anki._download_cat_image",
+ return_value=downloaded_content,
+ ) as mock_download,
+ patch("pathlib.Path.write_bytes") as mock_write,
+ ):
+ result = get_or_download_image(status_code, use_cache=True)
+
+ assert result == downloaded_content
+ mock_download.assert_called_once_with(status_code)
+ mock_write.assert_called_once_with(downloaded_content)
+
+ def test_ignores_cache_when_disabled(self) -> None:
+ """Test that cache is ignored when use_cache=False."""
+ status_code = 200
+ downloaded_content = b"fresh download"
+
+ with (
+ patch("pathlib.Path.exists", return_value=True),
+ patch("pathlib.Path.mkdir"),
+ patch(
+ "python_pkg.download_cats.http_status_anki._download_cat_image",
+ return_value=downloaded_content,
+ ) as mock_download,
+ patch("pathlib.Path.write_bytes"),
+ ):
+ result = get_or_download_image(status_code, use_cache=False)
+
+ assert result == downloaded_content
+ mock_download.assert_called_once_with(status_code)
+
+ def test_creates_cache_directory(self) -> None:
+ """Test that cache directory is created if it doesn't exist."""
+ with (
+ patch("pathlib.Path.exists", return_value=False),
+ patch("pathlib.Path.mkdir") as mock_mkdir,
+ patch(
+ "python_pkg.download_cats.http_status_anki._download_cat_image",
+ return_value=b"image",
+ ),
+ patch("pathlib.Path.write_bytes"),
+ ):
+ get_or_download_image(200, use_cache=True)
+
+ mock_mkdir.assert_called_once_with(parents=True, exist_ok=True)
+
+
+class TestGenerateAnkiPackage:
+ """Tests for generate_anki_package function."""
+
+ def test_creates_package_with_all_codes(self) -> None:
+ """Test that package is created with cards for all status codes."""
+ test_codes = {200: "OK", 404: "Not Found"}
+
+ with (
+ patch(
+ "python_pkg.download_cats.http_status_anki.get_or_download_image",
+ return_value=b"fake image",
+ ),
+ patch("pathlib.Path.write_bytes"),
+ ):
+ package = generate_anki_package(test_codes, use_cache=True)
+
+ # Should have 2 cards per status code (bidirectional)
+ assert len(package.decks[0].notes) == 4
+
+ def test_uses_correct_deck_name(self) -> None:
+ """Test that deck uses specified name."""
+ test_codes = {200: "OK"}
+ deck_name = "Test Deck"
+
+ with (
+ patch(
+ "python_pkg.download_cats.http_status_anki.get_or_download_image",
+ return_value=b"fake image",
+ ),
+ patch("pathlib.Path.write_bytes"),
+ ):
+ package = generate_anki_package(test_codes, deck_name, use_cache=True)
+
+ assert package.decks[0].name == deck_name
+
+ def test_handles_download_errors(self) -> None:
+ """Test that download errors are handled gracefully."""
+ test_codes = {200: "OK", 404: "Not Found"}
+
+ def mock_download(code: int, *, use_cache: bool) -> bytes: # noqa: ARG001
+ error_msg = "Failed"
+ if code == 404:
+ raise requests.exceptions.RequestException(error_msg)
+ return b"image"
+
+ with (
+ patch(
+ "python_pkg.download_cats.http_status_anki.get_or_download_image",
+ side_effect=mock_download,
+ ),
+ patch("pathlib.Path.write_bytes"),
+ ):
+ package = generate_anki_package(test_codes, use_cache=True)
+
+ # Should only have cards for successful downloads (200)
+ assert len(package.decks[0].notes) == 2 # 2 cards for status 200
+
+ def test_creates_media_files(self) -> None:
+ """Test that media files are created for images."""
+ test_codes = {200: "OK"}
+
+ with (
+ patch(
+ "python_pkg.download_cats.http_status_anki.get_or_download_image",
+ return_value=b"fake image",
+ ),
+ patch("pathlib.Path.write_bytes"),
+ ):
+ package = generate_anki_package(test_codes, use_cache=True)
+
+ assert len(package.media_files) == 1
+ assert "http_cat_200.jpg" in package.media_files[0]
+
+ def test_respects_cache_setting(self) -> None:
+ """Test that cache setting is passed to download function."""
+ test_codes = {200: "OK"}
+
+ with (
+ patch(
+ "python_pkg.download_cats.http_status_anki.get_or_download_image",
+ return_value=b"fake image",
+ ) as mock_get,
+ patch("pathlib.Path.write_bytes"),
+ ):
+ generate_anki_package(test_codes, use_cache=False)
+
+ mock_get.assert_called_with(200, use_cache=False)
+
+
+class TestMain:
+ """Tests for main function."""
+
+ def test_default_output_path(self) -> None:
+ """Test that default output path is used."""
+ with patch(
+ "python_pkg.download_cats.http_status_anki.generate_anki_package"
+ ) as mock_gen:
+ mock_package = MagicMock()
+ mock_gen.return_value = mock_package
+
+ result = main([])
+
+ assert result == 0
+ # Check that write_to_file was called with default path
+ call_args = mock_package.write_to_file.call_args[0][0]
+ assert "http_status_codes.apkg" in call_args
+
+ def test_custom_output_path(self) -> None:
+ """Test that custom output path is used."""
+ with patch(
+ "python_pkg.download_cats.http_status_anki.generate_anki_package"
+ ) as mock_gen:
+ mock_package = MagicMock()
+ mock_gen.return_value = mock_package
+
+ result = main(["--output", "custom.apkg"])
+
+ assert result == 0
+ call_args = mock_package.write_to_file.call_args[0][0]
+ assert "custom.apkg" in call_args
+
+ def test_custom_deck_name(self) -> None:
+ """Test that custom deck name is used."""
+ with patch(
+ "python_pkg.download_cats.http_status_anki.generate_anki_package"
+ ) as mock_gen:
+ mock_package = MagicMock()
+ mock_gen.return_value = mock_package
+
+ main(["--deck-name", "My Custom Deck"])
+
+ mock_gen.assert_called_once()
+ assert mock_gen.call_args[0][1] == "My Custom Deck"
+
+ def test_no_cache_option(self) -> None:
+ """Test that --no-cache option disables caching."""
+ with patch(
+ "python_pkg.download_cats.http_status_anki.generate_anki_package"
+ ) as mock_gen:
+ mock_package = MagicMock()
+ mock_gen.return_value = mock_package
+
+ main(["--no-cache"])
+
+ # use_cache should be False (not args.no_cache = True)
+ assert mock_gen.call_args[1]["use_cache"] is False
+
+ def test_error_handling(self) -> None:
+ """Test that errors are handled gracefully."""
+ with patch(
+ "python_pkg.download_cats.http_status_anki.generate_anki_package",
+ side_effect=RuntimeError("Test error"),
+ ):
+ result = main([])
+
+ assert result == 1
+
+ def test_verbose_logging(self) -> None:
+ """Test that verbose flag enables logging."""
+ with (
+ patch(
+ "python_pkg.download_cats.http_status_anki.generate_anki_package"
+ ) as mock_gen,
+ patch("logging.basicConfig") as mock_config,
+ ):
+ mock_package = MagicMock()
+ mock_gen.return_value = mock_package
+
+ main(["--verbose"])
+
+ # Check that logging was configured
+ mock_config.assert_called_once()
+ call_kwargs = mock_config.call_args[1]
+ assert call_kwargs["level"] == 20 # logging.INFO
+
+
+class TestConstants:
+ """Tests for module constants."""
+
+ def test_http_status_codes_not_empty(self) -> None:
+ """Test that HTTP_STATUS_CODES is populated."""
+ assert len(HTTP_STATUS_CODES) > 0
+
+ def test_common_status_codes_present(self) -> None:
+ """Test that common status codes are present."""
+ common_codes = [200, 201, 301, 400, 401, 403, 404, 500, 502, 503]
+ for code in common_codes:
+ assert code in HTTP_STATUS_CODES
+
+ def test_status_codes_have_descriptions(self) -> None:
+ """Test that all status codes have non-empty descriptions."""
+ for code, description in HTTP_STATUS_CODES.items():
+ assert isinstance(code, int)
+ assert isinstance(description, str)
+ assert len(description) > 0
+
+ def test_request_timeout_value(self) -> None:
+ """Test REQUEST_TIMEOUT constant has expected value."""
+ assert REQUEST_TIMEOUT == 30
+
+ def test_cache_dir_path(self) -> None:
+ """Test that CACHE_DIR is properly configured."""
+ assert isinstance(CACHE_DIR, Path)
+ assert "http_cat_cache" in str(CACHE_DIR)