From b200d8957f06b8dc85214fac5595270c81fc0527 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 7 Jan 2026 21:24:59 +0000
Subject: [PATCH] Address PR feedback: use genanki for self-contained .apkg,
fix tests, update README
Co-authored-by: kuhyx <147418882+kuhyx@users.noreply.github.com>
---
python_pkg/warsaw_districts/README.md | 63 +++---
.../tests/test_warsaw_districts_anki.py | 153 ++++++---------
.../warsaw_districts/warsaw_districts_anki.py | 185 ++++++++++--------
requirements.txt | 1 +
4 files changed, 196 insertions(+), 206 deletions(-)
diff --git a/python_pkg/warsaw_districts/README.md b/python_pkg/warsaw_districts/README.md
index 79d118e..2e79f33 100644
--- a/python_pkg/warsaw_districts/README.md
+++ b/python_pkg/warsaw_districts/README.md
@@ -7,13 +7,34 @@ Generate Anki flashcards for learning the 18 districts (dzielnice) of Warsaw, Po
- Generates flashcards for all 18 Warsaw districts
- Front of card: Map showing only the district in question with its borders highlighted
- Back of card: District name in Polish
-- Anki-compatible output format (semicolon-separated)
+- Self-contained .apkg file with embedded images
- Compatible with AnkiWeb and AnkiDroid
## Installation
+Install dependencies using your preferred method:
+
+### Using pyenv (recommended)
```bash
-pip install matplotlib
+pyenv install 3.10 # or later
+pyenv shell 3.10
+pip install matplotlib genanki
+```
+
+### Using pipx
+```bash
+pipx install --python python3.10 matplotlib genanki
+```
+
+### Using system package manager (Arch Linux)
+```bash
+sudo pacman -S python-matplotlib
+pip install genanki
+```
+
+### Using pip directly
+```bash
+pip install matplotlib genanki
```
## Usage
@@ -26,32 +47,26 @@ python -m python_pkg.warsaw_districts.warsaw_districts_anki
```
This creates:
-- `warsaw_districts_anki.txt` - Anki import file
-- `warsaw_districts_images/` - Directory with 18 PNG map images
+- `warsaw_districts.apkg` - Self-contained Anki package with all images embedded
### Custom options
```bash
-# Custom output file and image directory
-python -m python_pkg.warsaw_districts.warsaw_districts_anki \
- --output my_cards.txt \
- --image-dir my_maps
+# Custom output file
+python -m python_pkg.warsaw_districts.warsaw_districts_anki --output my_cards.apkg
# Custom deck name
-python -m python_pkg.warsaw_districts.warsaw_districts_anki \
- --deck-name "Warszawa - Dzielnice"
+python -m python_pkg.warsaw_districts.warsaw_districts_anki --deck-name "Warszawa - Dzielnice"
```
## Importing into Anki
1. Open Anki
2. File → Import
-3. Select the generated `warsaw_districts_anki.txt` file
-4. Copy all images from `warsaw_districts_images/` to your Anki profile's `collection.media` folder
- - On Linux: `~/.local/share/Anki2/[Profile]/collection.media/`
- - On Windows: `%APPDATA%\Anki2\[Profile]\collection.media\`
- - On macOS: `~/Library/Application Support/Anki2/[Profile]/collection.media/`
-5. Click Import
+3. Select the generated `warsaw_districts.apkg` file
+4. Click Import
+
+That's it! All images are already embedded in the .apkg file.
## Warsaw Districts
@@ -76,22 +91,6 @@ The generator includes all 18 official districts of Warsaw:
17. Wola
18. Żoliborz
-## Output Format
-
-The generated file uses Anki's standard import format:
-
-```
-#separator:semicolon
-#html:true
-#deck:Warsaw Districts
-#tags:geography warsaw poland
-#columns:Front;Back
-
-
;Bemowo
-
;Białołęka
-...
-```
-
## Development
### Running tests
diff --git a/python_pkg/warsaw_districts/tests/test_warsaw_districts_anki.py b/python_pkg/warsaw_districts/tests/test_warsaw_districts_anki.py
index 9a276d5..33a3739 100644
--- a/python_pkg/warsaw_districts/tests/test_warsaw_districts_anki.py
+++ b/python_pkg/warsaw_districts/tests/test_warsaw_districts_anki.py
@@ -11,9 +11,9 @@ try:
from python_pkg.warsaw_districts.warsaw_districts_anki import (
WARSAW_DISTRICTS,
create_district_map,
- generate_anki_deck,
+ generate_anki_package,
+ generate_district_image_bytes,
main,
- save_district_image,
)
except ImportError:
import sys
@@ -22,9 +22,9 @@ except ImportError:
from python_pkg.warsaw_districts.warsaw_districts_anki import (
WARSAW_DISTRICTS,
create_district_map,
- generate_anki_deck,
+ generate_anki_package,
+ generate_district_image_bytes,
main,
- save_district_image,
)
@@ -54,13 +54,30 @@ class TestDistricts:
assert len(names) == len(set(names))
def test_known_districts_present(self) -> None:
- """Test that known Warsaw districts are in the list."""
+ """Test that all known Warsaw districts are in the list."""
district_names = {d.name for d in WARSAW_DISTRICTS}
- # Check a few well-known districts
- assert "Śródmieście" in district_names
- assert "Mokotów" in district_names
- assert "Praga-Północ" in district_names
- assert "Żoliborz" in district_names
+ # Check all 18 districts
+ expected_districts = {
+ "Bemowo",
+ "Białołęka",
+ "Bielany",
+ "Mokotów",
+ "Ochota",
+ "Praga-Południe",
+ "Praga-Północ",
+ "Rembertów",
+ "Śródmieście",
+ "Targówek",
+ "Ursus",
+ "Ursynów",
+ "Wawer",
+ "Wesoła",
+ "Wilanów",
+ "Włochy",
+ "Wola",
+ "Żoliborz",
+ }
+ assert district_names == expected_districts
class TestCreateDistrictMap:
@@ -82,68 +99,44 @@ class TestCreateDistrictMap:
plt.close(fig)
-class TestSaveDistrictImage:
- """Tests for saving district images."""
+class TestGenerateDistrictImageBytes:
+ """Tests for generating district image bytes."""
- def test_saves_image_file(self, tmp_path: Path) -> None:
- """Test that save_district_image creates a file."""
+ def test_generates_bytes(self) -> None:
+ """Test that generate_district_image_bytes returns bytes."""
district = WARSAW_DISTRICTS[0]
- image_path = save_district_image(district, tmp_path)
+ image_bytes = generate_district_image_bytes(district)
+ assert isinstance(image_bytes, bytes)
+ assert len(image_bytes) > 0
- assert image_path.exists()
- assert image_path.suffix == ".png"
- assert image_path.parent == tmp_path
-
- def test_saves_all_district_images(self, tmp_path: Path) -> None:
- """Test that we can save images for all districts."""
+ def test_generates_for_all_districts(self) -> None:
+ """Test that we can generate images for all districts."""
for district in WARSAW_DISTRICTS:
- image_path = save_district_image(district, tmp_path)
- assert image_path.exists()
+ image_bytes = generate_district_image_bytes(district)
+ assert isinstance(image_bytes, bytes)
+ assert len(image_bytes) > 0
-class TestGenerateAnkiDeck:
- """Tests for generating Anki deck content."""
+class TestGenerateAnkiPackage:
+ """Tests for generating Anki package."""
- def test_generates_valid_header(self, tmp_path: Path) -> None:
- """Test that output contains valid Anki headers."""
- result = generate_anki_deck(tmp_path, "Test Deck")
+ def test_generates_package(self) -> None:
+ """Test that output is a genanki Package."""
+ package = generate_anki_package("Test Deck")
+ assert package is not None
+ assert len(package.decks) == 1
- assert "#separator:semicolon" in result
- assert "#deck:Test Deck" in result
- assert "#html:true" in result
+ def test_generates_notes_for_all_districts(self) -> None:
+ """Test that package contains cards for all 18 districts."""
+ package = generate_anki_package()
+ deck = package.decks[0]
+ assert len(deck.notes) == len(WARSAW_DISTRICTS)
- def test_generates_flashcard_for_all_districts(self, tmp_path: Path) -> None:
- """Test that output contains cards for all 18 districts."""
- result = generate_anki_deck(tmp_path)
-
- # Check that all district names appear in the output
- for district in WARSAW_DISTRICTS:
- assert district.name in result
-
- def test_generates_images_for_all_districts(self, tmp_path: Path) -> None:
- """Test that images are generated for all districts."""
- generate_anki_deck(tmp_path)
-
- # Check that all image files were created
- image_files = list(tmp_path.glob("*.png"))
- assert len(image_files) == 18
-
- def test_output_format(self, tmp_path: Path) -> None:
- """Test that output has correct semicolon-separated format."""
- result = generate_anki_deck(tmp_path)
-
- lines = result.split("\n")
- # Skip header lines and empty lines
- data_lines = [line for line in lines if line and not line.startswith("#")]
-
- # Each data line should have exactly 2 fields (front;back)
- for line in data_lines:
- fields = line.split(";")
- assert len(fields) == 2
- # Front should contain None:
"""Test that main creates the output file."""
- output_file = tmp_path / "test_output.txt"
- image_dir = tmp_path / "images"
+ output_file = tmp_path / "test_output.apkg"
result = main(
[
"--output",
str(output_file),
- "--image-dir",
- str(image_dir),
]
)
assert result == 0
assert output_file.exists()
- assert image_dir.exists()
-
- def test_creates_images(self, tmp_path: Path) -> None:
- """Test that main creates image files."""
- output_file = tmp_path / "test_output.txt"
- image_dir = tmp_path / "images"
-
- main(
- [
- "--output",
- str(output_file),
- "--image-dir",
- str(image_dir),
- ]
- )
-
- image_files = list(image_dir.glob("*.png"))
- assert len(image_files) == 18
def test_custom_deck_name(self, tmp_path: Path) -> None:
"""Test that custom deck name is used."""
- output_file = tmp_path / "test_output.txt"
- image_dir = tmp_path / "images"
+ output_file = tmp_path / "test_output.apkg"
- main(
+ result = main(
[
"--output",
str(output_file),
- "--image-dir",
- str(image_dir),
"--deck-name",
"Custom Deck",
]
)
- content = output_file.read_text()
- assert "#deck:Custom Deck" in content
+ assert result == 0
+ assert output_file.exists()
def test_help_flag(self) -> None:
"""Test that --help works."""
diff --git a/python_pkg/warsaw_districts/warsaw_districts_anki.py b/python_pkg/warsaw_districts/warsaw_districts_anki.py
index 2b0a120..08b3a37 100755
--- a/python_pkg/warsaw_districts/warsaw_districts_anki.py
+++ b/python_pkg/warsaw_districts/warsaw_districts_anki.py
@@ -9,23 +9,24 @@ Usage:
python -m python_pkg.warsaw_districts.warsaw_districts_anki
# Specify custom output file
- python -m python_pkg.warsaw_districts.warsaw_districts_anki --output warsaw.txt
-
- # Specify custom output directory for images
- python -m python_pkg.warsaw_districts.warsaw_districts_anki --image-dir ./maps
+ python -m python_pkg.warsaw_districts.warsaw_districts_anki --output warsaw.apkg
Output:
- Creates a semicolon-separated text file that can be imported into Anki.
- Format:
;district_name_in_polish
+ Creates a self-contained .apkg file that can be directly imported into Anki.
+ The file includes all images embedded, so no manual file copying is needed.
"""
from __future__ import annotations
import argparse
+import hashlib
+from io import BytesIO
from pathlib import Path
+import random
import sys
from typing import TYPE_CHECKING, NamedTuple
+import genanki
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
@@ -134,67 +135,99 @@ def create_district_map(district: District, *, highlight_only: bool = True) -> F
return fig
-def save_district_image(district: District, output_dir: Path) -> Path:
- """Save a district map image to a file.
+def generate_district_image_bytes(district: District) -> bytes:
+ """Generate a district map image as bytes.
Args:
district: The district to visualize.
- output_dir: Directory to save the image.
Returns:
- Path to the saved image file.
+ PNG image data as bytes.
"""
- output_dir.mkdir(parents=True, exist_ok=True)
fig = create_district_map(district)
- # Create filename from district name (sanitized)
- filename = f"{district.name.replace('-', '_').replace(' ', '_')}.png"
- output_path = output_dir / filename
-
- fig.savefig(output_path, format="png", bbox_inches="tight", dpi=150)
+ # Save to bytes buffer
+ buf = BytesIO()
+ fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
plt.close(fig)
+ buf.seek(0)
- return output_path
+ return buf.read()
-def generate_anki_deck(
- output_dir: Path,
+def generate_anki_package(
deck_name: str = "Warsaw Districts",
-) -> str:
- """Generate Anki-compatible deck content for Warsaw districts.
+) -> genanki.Package:
+ """Generate Anki package (.apkg) for Warsaw districts.
Args:
- output_dir: Directory where images will be saved.
deck_name: Name for the Anki deck.
Returns:
- Semicolon-separated content ready for Anki import.
+ genanki.Package object ready to be written to file.
"""
- lines: list[str] = []
+ # Create a unique model ID based on deck name
+ model_id_hash = hashlib.md5( # noqa: S324
+ f"warsaw_districts_{deck_name}".encode()
+ )
+ model_id = int(model_id_hash.hexdigest()[:8], 16)
- # Add Anki headers
- lines.append("#separator:semicolon")
- lines.append("#html:true")
- lines.append(f"#deck:{deck_name}")
- lines.append("#tags:geography warsaw poland")
- lines.append("#columns:Front;Back")
- lines.append("") # Empty line before data
+ # Define the note model (card template)
+ my_model = genanki.Model(
+ model_id,
+ "Warsaw District Model",
+ fields=[
+ {"name": "DistrictMap"},
+ {"name": "DistrictName"},
+ ],
+ templates=[
+ {
+ "name": "Card 1",
+ "qfmt": "{{DistrictMap}}",
+ "afmt": '{{FrontSide}}