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)