Address PR feedback: use genanki for self-contained .apkg, fix tests, update README

Co-authored-by: kuhyx <147418882+kuhyx@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-01-07 21:24:59 +00:00
parent e1afa45b66
commit b200d8957f
4 changed files with 196 additions and 206 deletions

View File

@ -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
<img src="Bemowo.png">;Bemowo
<img src="Białołęka.png">;Białołęka
...
```
## Development
### Running tests

View File

@ -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 <img src=
assert "<img src=" in fields[0]
# Back should be a district name
assert fields[1] in {d.name for d in WARSAW_DISTRICTS}
def test_custom_deck_name(self) -> None:
"""Test that custom deck name is used."""
package = generate_anki_package("Custom Deck")
deck = package.decks[0]
assert deck.name == "Custom Deck"
class TestMain:
@ -151,57 +144,33 @@ class TestMain:
def test_creates_output_file(self, tmp_path: Path) -> 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."""

View File

@ -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: <img src="district_name.png">;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}}<hr id="answer">{{DistrictName}}',
},
],
)
# Generate cards for each district
# Create a unique deck ID based on deck name
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
# Create the deck
my_deck = genanki.Deck(deck_id, deck_name)
# Store media files
media_files = []
# Generate notes for each district
for district in WARSAW_DISTRICTS:
# Save the image
image_path = save_district_image(district, output_dir)
# Generate image
image_data = generate_district_image_bytes(district)
# Create the front side: reference to image
# Anki expects the image filename to be in the media collection
front = f'<img src="{image_path.name}">'
# Create unique filename
filename = f"{district.name.replace('-', '_').replace(' ', '_')}.png"
# Back side: district name in Polish
back = district.name
# Create note
note = genanki.Note(
model=my_model,
fields=[
f'<img src="{filename}">',
district.name,
],
tags=["geography", "warsaw", "poland"],
)
lines.append(f"{front};{back}")
my_deck.add_note(note)
return "\n".join(lines)
# Save image data to temporary file for packaging
temp_path = Path(f"/tmp/{filename}") # noqa: S108
temp_path.write_bytes(image_data)
media_files.append(str(temp_path))
# Create package
package = genanki.Package(my_deck)
package.media_files = media_files
return package
def main(argv: Sequence[str] | None = None) -> int:
@ -217,14 +250,7 @@ def main(argv: Sequence[str] | None = None) -> int:
"-o",
type=str,
default=None,
help="Output file path (default: warsaw_districts_anki.txt)",
)
parser.add_argument(
"--image-dir",
"-i",
type=str,
default=None,
help="Directory for district images (default: warsaw_districts_images)",
help="Output file path (default: warsaw_districts.apkg)",
)
parser.add_argument(
"--deck-name",
@ -236,44 +262,39 @@ def main(argv: Sequence[str] | None = None) -> int:
args = parser.parse_args(argv)
# Determine output paths
if args.output:
output_path = Path(args.output)
else:
output_path = Path("warsaw_districts_anki.txt")
if args.image_dir:
image_dir = Path(args.image_dir)
else:
image_dir = Path("warsaw_districts_images")
# Determine output path
output_path = (
Path(args.output) if args.output else Path("warsaw_districts.apkg")
)
try:
print(f"Generating flashcards for {len(WARSAW_DISTRICTS)} Warsaw districts...") # noqa: T201
num_districts = len(WARSAW_DISTRICTS)
sys.stdout.write(
f"Generating flashcards for {num_districts} Warsaw districts...\n"
)
# Generate the deck content
anki_content = generate_anki_deck(image_dir, args.deck_name)
# Generate the package
package = generate_anki_package(args.deck_name)
# Write output file
output_path.write_text(anki_content, encoding="utf-8")
# Write to file
package.write_to_file(str(output_path))
print() # noqa: T201
print("=" * 60) # noqa: T201
print("FLASHCARD GENERATION COMPLETE") # noqa: T201
print("=" * 60) # noqa: T201
print(f"Districts: {len(WARSAW_DISTRICTS)}") # noqa: T201
print(f"Images directory: {image_dir.absolute()}") # noqa: T201
print(f"Output file: {output_path.absolute()}") # noqa: T201
print() # noqa: T201
print("To import into Anki:") # noqa: T201
print(" 1. Open Anki") # noqa: T201
print(" 2. File -> Import") # noqa: T201
print(f" 3. Select: {output_path.absolute()}") # noqa: T201
img_dir = image_dir.absolute()
print(f" 4. Ensure images from {img_dir} are in Anki's media folder") # noqa: T201
print(" or copy them to your Anki profile's collection.media folder") # noqa: T201
print(" 5. Click Import") # noqa: T201
except Exception as e: # noqa: BLE001
print(f"Error: {e}", file=sys.stderr) # noqa: T201
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"Districts: {num_districts}\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
sys.stdout.write("\n")
sys.stdout.write("To import into Anki:\n")
sys.stdout.write(" 1. Open Anki\n")
sys.stdout.write(" 2. File -> Import\n")
sys.stdout.write(f" 3. Select: {output_path.absolute()}\n")
sys.stdout.write(" 4. Click Import\n")
sys.stdout.write("\n")
sys.stdout.write("All images are embedded in the .apkg file!\n")
except (OSError, ValueError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0

View File

@ -1,6 +1,7 @@
beautifulsoup4>=4.0
berserk>=0.13
bottle>=0.12
genanki>=0.13
lxml>=5.0
# Optional dependencies for specific scripts (needed for full pylint analysis)