Add Polish license plate Anki flashcard generator with Wikipedia data extraction and caching (#4)

* Initial plan

* Add Polish license plate Anki generator with bidirectional cards

Co-authored-by: kuhyx <147418882+kuhyx@users.noreply.github.com>

* Add comprehensive README for Polish license plates package

Co-authored-by: kuhyx <147418882+kuhyx@users.noreply.github.com>

* Fix license plate data: correct WT (Wawer) and WWY (Wyszków) mappings

Co-authored-by: kuhyx <147418882+kuhyx@users.noreply.github.com>

* Add Wikipedia scraper for automatic license plate data extraction

Co-authored-by: kuhyx <147418882+kuhyx@users.noreply.github.com>

* Improve fetch_license_plates: add constants and update User-Agent

Co-authored-by: kuhyx <147418882+kuhyx@users.noreply.github.com>

* Add caching to Wikipedia scraper to avoid unnecessary requests

Co-authored-by: kuhyx <147418882+kuhyx@users.noreply.github.com>

* Add error handling for cache file operations

Co-authored-by: kuhyx <147418882+kuhyx@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: kuhyx <147418882+kuhyx@users.noreply.github.com>
This commit is contained in:
Copilot 2026-01-18 14:30:22 +01:00 committed by GitHub
parent 652e86c370
commit 7a211649b9
9 changed files with 1512 additions and 1 deletions

3
.gitignore vendored
View File

@ -273,3 +273,6 @@ python_pkg/download_cats/http_cat_cache/
# Large geojson files that can be downloaded # Large geojson files that can be downloaded
python_pkg/warsaw_districts/warszawa-dzielnice.geojson python_pkg/warsaw_districts/warszawa-dzielnice.geojson
# Wikipedia cache (can be refreshed)
python_pkg/polish_license_plates/.wikipedia_cache/

View File

@ -162,7 +162,7 @@ repos:
- id: codespell - id: codespell
args: args:
- --skip=*.json,*.lock,*.min.js,*.min.css,.git,__pycache__,.venv,*.txt - --skip=*.json,*.lock,*.min.js,*.min.css,.git,__pycache__,.venv,*.txt
- --ignore-words-list=ans,ect,nd,som,sur,te,nam,numer,lew,sie,wil,postion,clen,ther,folow,derrive - --ignore-words-list=ans,ect,nd,som,sur,te,nam,numer,lew,sie,wil,postion,clen,ther,folow,derrive,ony,tje,noe
exclude: ^(Bash/ffmpeg-build/|LaTeX/|CPP/) exclude: ^(Bash/ffmpeg-build/|LaTeX/|CPP/)
# =========================================================================== # ===========================================================================

View File

@ -0,0 +1,151 @@
# Polish License Plates Anki Generator
Generate Anki flashcards for learning Polish car license plate codes.
## Overview
This package generates Anki-compatible flashcard decks for all Polish vehicle registration plate codes. Each code is mapped to its corresponding location (city or powiat).
Polish license plates use a system where:
- First letter indicates the **voivodeship** (province)
- Following 1-2 letters indicate the specific **city** or **powiat** (county)
## Features
- **444 license plate codes** covering all Polish voivodeships, cities, and powiats
- **Bidirectional flashcards**:
- Code → Location (e.g., `WY``Warszawa Wola`)
- Location → Code (e.g., `Warszawa Wola``WY`)
- **888 total flashcards** for comprehensive learning
- Visual license plate styling in flashcards (yellow background, monospace font)
- Dark mode support
- Self-contained `.apkg` file - no manual setup required
## Data Source
License plate data is automatically extracted from Wikipedia's authoritative table:
- **Source**: [Vehicle registration plates of Poland](https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland)
- **Update**: Run `python -m python_pkg.polish_license_plates.fetch_license_plates` to refresh data
This ensures the codes are always based on the most current public information.
## Usage
### Generate Flashcards
```bash
# Generate with default settings
python -m python_pkg.polish_license_plates.polish_license_plates_anki
# Specify custom output file
python -m python_pkg.polish_license_plates.polish_license_plates_anki \
--output my_plates.apkg
# Use custom deck name
python -m python_pkg.polish_license_plates.polish_license_plates_anki \
--deck-name "My Polish Plates"
```
### Update License Plate Data
To fetch the latest data from Wikipedia:
```bash
# Use cached data if available (default)
python -m python_pkg.polish_license_plates.fetch_license_plates
# Force refresh from Wikipedia (ignore cache)
python -m python_pkg.polish_license_plates.fetch_license_plates --force
```
**Caching**: Downloaded Wikipedia data is cached for 7 days in `.wikipedia_cache/` to avoid unnecessary requests. Use `--force` to bypass the cache.
This will update `license_plate_data.py` with the current codes from Wikipedia.
**Requirements**: `pip install requests beautifulsoup4 lxml`
### Import into Anki
1. Open Anki
2. File → Import
3. Select the generated `.apkg` file
4. Click Import
## Examples
### License Plate Codes by Voivodeship
| Voivodeship | First Letter | Example Codes |
| ------------------- | ------------ | ------------------------------------------------ |
| Dolnośląskie | D | DA (Wrocław), DB (Wałbrzych), DJ (Jelenia Góra) |
| Kujawsko-Pomorskie | C | CB (Bydgoszcz), CT (Toruń), CG (Grudziądz) |
| Lubelskie | L | LL (Lublin), LC (Chełm), LZ (Zamość) |
| Lubuskie | F | FZ (Zielona Góra), FG (Gorzów Wielkopolski) |
| Łódzkie | E | ED (Łódź), EP (Piotrków Trybunalski) |
| Małopolskie | K | KR (Kraków), KT (Tarnów), KN (Nowy Sącz) |
| Mazowieckie | W | WA-WZ (Warsaw), WR (Radom), WS (Siedlce) |
| Opolskie | O | OP (Opole), OK (Kędzierzyn-Koźle) |
| Podkarpackie | R | RR (Rzeszów), RP (Przemyśl), RK (Krosno) |
| Podlaskie | B | BI (Białystok), BL (Łomża), BSU (Suwałki) |
| Pomorskie | G | GD (Gdańsk), GDY (Gdynia), GS (Słupsk) |
| Śląskie | S | SK (Katowice), SC (Chorzów), SB (Bielsko-Biała) |
| Świętokrzyskie | T | TK (Kielce), TSK (Skarżysko-Kamienna) |
| Warmińsko-Mazurskie | N | NO (Olsztyn), NE (Elbląg), NG (Giżycko) |
| Wielkopolskie | P | PO (Poznań), PKA (Kalisz), PIA (Piła) |
| Zachodniopomorskie | Z | ZS (Szczecin), ZKO (Koszalin), ZSW (Świnoujście) |
### Warsaw (Warszawa) Codes
Warsaw has an extensive range of codes (WA-WZ):
- WA: Warszawa (general)
- WB: Warszawa Bemowo
- WC: Ciechanów
- WD: Warszawa Praga Południe
- WE: Warszawa Praga Północ
- WH: Warszawa Mokotów
- WY: Warszawa Wola
- And many more...
## Data
The package includes 444 license plate codes covering:
- All 16 Polish voivodeships
- Major cities with powiat rights (e.g., Kraków, Gdańsk, Poznań)
- All powiats (counties) across Poland
## Testing
Run the test suite:
```bash
python -m pytest python_pkg/polish_license_plates/tests/ -v
```
All 17 tests validate:
- Data integrity (444 codes, no duplicates)
- Correct voivodeship prefixes
- Major cities present
- Anki package generation
- Bidirectional card templates
- CLI functionality
## Technical Details
- **Package format**: Anki `.apkg` (SQLite database)
- **Card model**: Bidirectional with two templates per note
- **Styling**: Custom CSS with license plate visual design
- **Tags**: `geography`, `poland`, `license-plates`, `transportation`
## Requirements
- Python 3.10+
- genanki (for Anki package generation)
## License
Part of the testsAndMisc repository.

View File

@ -0,0 +1,7 @@
"""Polish license plate Anki flashcard generator."""
from __future__ import annotations
__all__ = ["LICENSE_PLATE_CODES"]
from python_pkg.polish_license_plates.license_plate_data import LICENSE_PLATE_CODES

View File

@ -0,0 +1,387 @@
#!/usr/bin/env python3
"""Fetch Polish license plate codes from Wikipedia.
This script scrapes the Wikipedia page "Vehicle registration plates of Poland"
to extract the official license plate codes and their corresponding locations.
The data is extracted from the wikitable on the page and saved to license_plate_data.py.
Caching:
Fetched Wikipedia HTML is cached to avoid unnecessary requests.
Cache location: .wikipedia_cache/license_plates.html
Cache expires after 7 days by default.
Usage:
python -m python_pkg.polish_license_plates.fetch_license_plates
# Force refresh (ignore cache)
python -m python_pkg.polish_license_plates.fetch_license_plates --force
Source:
https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland
Note:
This script requires internet access and the following packages:
- requests
- beautifulsoup4
- lxml
"""
from __future__ import annotations
import argparse
from pathlib import Path
import re
import sys
import time
try:
from bs4 import BeautifulSoup
import requests
except ImportError:
sys.stderr.write(
"Error: Required packages not installed.\n"
"Install with: pip install requests beautifulsoup4 lxml\n"
)
sys.exit(1)
# Constants
MIN_TABLE_COLUMNS = 2 # Minimum columns needed to extract code and location
MAX_CODE_LENGTH = 4 # Maximum length for a valid license plate code
CACHE_EXPIRY_DAYS = 7 # Cache expires after 7 days
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36" # Updated to recent version
)
def get_cache_path() -> Path:
"""Get the path to the cache file.
Returns:
Path to the cache file.
"""
script_dir = Path(__file__).parent
cache_dir = script_dir / ".wikipedia_cache"
cache_dir.mkdir(exist_ok=True)
return cache_dir / "license_plates.html"
def is_cache_valid(cache_path: Path, max_age_days: int = CACHE_EXPIRY_DAYS) -> bool:
"""Check if the cache file exists and is not expired.
Args:
cache_path: Path to the cache file.
max_age_days: Maximum age in days before cache is considered expired.
Returns:
True if cache is valid, False otherwise.
"""
if not cache_path.exists():
return False
# Check age
file_age_seconds = time.time() - cache_path.stat().st_mtime
max_age_seconds = max_age_days * 24 * 60 * 60
return file_age_seconds < max_age_seconds
def fetch_wikipedia_html(*, force_refresh: bool = False) -> str:
"""Fetch Wikipedia HTML, using cache if available.
Args:
force_refresh: If True, ignore cache and fetch fresh data.
Returns:
HTML content of the Wikipedia page.
Raises:
RuntimeError: If the page cannot be fetched.
"""
cache_path = get_cache_path()
# Check if we can use cache
if not force_refresh and is_cache_valid(cache_path):
try:
sys.stdout.write(f"Using cached data from {cache_path}\n")
cache_age_hours = int((time.time() - cache_path.stat().st_mtime) / 3600)
sys.stdout.write(f"Cache age: {cache_age_hours} hours\n")
return cache_path.read_text(encoding="utf-8")
except OSError as e:
sys.stderr.write(f"Warning: Failed to read cache: {e}\n")
sys.stderr.write("Fetching fresh data from Wikipedia...\n")
# Fall through to fetch from Wikipedia
# Fetch from Wikipedia
url = "https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland"
headers = {"User-Agent": USER_AGENT}
if force_refresh:
sys.stdout.write("Force refresh: Ignoring cache\n")
sys.stdout.write(f"Fetching data from {url}...\n")
try:
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
except requests.RequestException as e:
msg = f"Failed to fetch Wikipedia page: {e}"
raise RuntimeError(msg) from e
# Cache the response
try:
cache_path.write_text(response.text, encoding="utf-8")
sys.stdout.write(f"Cached response to {cache_path}\n")
except OSError as e:
sys.stderr.write(f"Warning: Failed to write cache: {e}\n")
# Continue anyway - the data was fetched successfully
return response.text
def parse_license_plates_from_html(html_content: str) -> dict[str, str]:
"""Parse license plate codes from Wikipedia HTML.
Args:
html_content: HTML content of the Wikipedia page.
Returns:
Dictionary mapping license plate codes to their locations.
Raises:
RuntimeError: If no valid tables are found.
"""
soup = BeautifulSoup(html_content, "html.parser")
# Find all wikitables
tables = soup.find_all("table", {"class": "wikitable"})
if not tables:
msg = "No wikitable found on the page"
raise RuntimeError(msg)
sys.stdout.write(f"Found {len(tables)} tables on the page\n")
license_plates: dict[str, str] = {}
# Process each table
for table_idx, table in enumerate(tables):
rows = table.find_all("tr")
sys.stdout.write(f"Processing table {table_idx + 1} with {len(rows)} rows...\n")
for row in rows[1:]: # Skip header row
cells = row.find_all(["td", "th"])
if len(cells) >= MIN_TABLE_COLUMNS:
# Extract code and location
code_text = cells[0].get_text(strip=True)
location_text = cells[1].get_text(strip=True)
# Clean up the code (remove spaces, keep only letters)
code = re.sub(r"[^A-Z]", "", code_text.upper())
# Skip if code is invalid
if not code or len(code) > MAX_CODE_LENGTH:
continue
# Clean up location text (remove citations, extra spaces)
location = re.sub(r"\[[0-9]+\]", "", location_text)
location = " ".join(location.split())
if location:
license_plates[code] = location
sys.stdout.write(f"Extracted {len(license_plates)} license plate codes\n")
return license_plates
def fetch_wikipedia_license_plates(*, force_refresh: bool = False) -> dict[str, str]:
"""Fetch Polish license plate codes from Wikipedia.
Args:
force_refresh: If True, ignore cache and fetch fresh data.
Returns:
Dictionary mapping license plate codes to their locations.
Raises:
RuntimeError: If the page cannot be fetched or parsed.
"""
html_content = fetch_wikipedia_html(force_refresh=force_refresh)
return parse_license_plates_from_html(html_content)
def generate_license_plate_data_file(
license_plates: dict[str, str],
output_path: Path,
) -> None:
"""Generate license_plate_data.py file with the extracted data.
Args:
license_plates: Dictionary mapping codes to locations.
output_path: Path to the output file.
"""
# Group by first letter (voivodeship)
voivodeships: dict[str, list[tuple[str, str]]] = {}
for code, location in sorted(license_plates.items()):
first_letter = code[0]
if first_letter not in voivodeships:
voivodeships[first_letter] = []
voivodeships[first_letter].append((code, location))
# Voivodeship names
voivodeship_names = {
"B": "Podlaskie",
"C": "Kujawsko-Pomorskie",
"D": "Dolnośląskie",
"E": "Łódzkie",
"F": "Lubuskie",
"G": "Pomorskie",
"K": "Małopolskie",
"L": "Lubelskie",
"N": "Warmińsko-Mazurskie",
"O": "Opolskie",
"P": "Wielkopolskie",
"R": "Podkarpackie",
"S": "Śląskie",
"T": "Świętokrzyskie",
"W": "Mazowieckie",
"Z": "Zachodniopomorskie",
}
# Generate file content
content = '''"""Database of Polish car license plate registration codes.
This module contains a comprehensive mapping of Polish vehicle registration
plate codes to their corresponding locations (cities, powiats, voivodeships).
Polish license plates use a system where:
- First letter indicates the voivodeship (province)
- Following 1-2 letters indicate the specific city or powiat (county)
The database is organized by voivodeships in alphabetical order:
- B: Podlaskie
- C: Kujawsko-Pomorskie
- D: Dolnośląskie
- E: Łódzkie
- F: Lubuskie
- G: Pomorskie
- K: Małopolskie
- L: Lubelskie
- N: Warmińsko-Mazurskie
- O: Opolskie
- P: Wielkopolskie
- R: Podkarpackie
- S: Śląskie
- T: Świętokrzyskie
- W: Mazowieckie
- Z: Zachodniopomorskie
Data source:
Wikipedia - Vehicle registration plates of Poland
https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland
Auto-generated by:
python -m python_pkg.polish_license_plates.fetch_license_plates
Examples:
WA = Warszawa (Warsaw)
KR = Kraków
GD = Gdańsk
"""
from __future__ import annotations
LICENSE_PLATE_CODES: dict[str, str] = {
'''
# Add entries grouped by voivodeship
for letter in sorted(voivodeships.keys()):
voivodeship_name = voivodeship_names.get(letter, f"Voivodeship {letter}")
codes = voivodeships[letter]
content += f" # {letter} - {voivodeship_name} ({len(codes)} codes)\n"
for code, location in codes:
# Escape quotes in location
location_escaped = location.replace('"', '\\"')
content += f' "{code}": "{location_escaped}",\n'
content += "\n"
# Remove last comma and newline, then close the dict
content = content.rstrip(",\n") + "\n}\n"
# Write to file
output_path.write_text(content, encoding="utf-8")
sys.stdout.write(f"Generated {output_path}\n")
def main() -> int:
"""Main entry point.
Returns:
Exit code.
"""
parser = argparse.ArgumentParser(
description="Fetch Polish license plate codes from Wikipedia.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--force",
"-f",
action="store_true",
help="Force refresh: ignore cache and fetch fresh data from Wikipedia",
)
args = parser.parse_args()
try:
# Fetch data from Wikipedia
license_plates = fetch_wikipedia_license_plates(force_refresh=args.force)
# Determine output path
script_dir = Path(__file__).parent
output_path = script_dir / "license_plate_data.py"
# Generate the file
generate_license_plate_data_file(license_plates, output_path)
sys.stdout.write("\n")
sys.stdout.write("=" * 70 + "\n")
sys.stdout.write("LICENSE PLATE DATA UPDATE COMPLETE\n")
sys.stdout.write("=" * 70 + "\n")
sys.stdout.write(f"Total codes: {len(license_plates)}\n")
sys.stdout.write(f"Output file: {output_path}\n")
sys.stdout.write("\n")
sys.stdout.write("Data source: Wikipedia\n")
sys.stdout.write(
"URL: https://en.wikipedia.org/wiki/"
"Vehicle_registration_plates_of_Poland\n"
)
sys.stdout.write(f"Cache location: {get_cache_path()}\n")
sys.stdout.write(f"Cache expiry: {CACHE_EXPIRY_DAYS} days\n")
sys.stdout.write("\n")
sys.stdout.write("Next steps:\n")
sys.stdout.write(" 1. Review the generated file\n")
sys.stdout.write(
" 2. Run tests: " "pytest python_pkg/polish_license_plates/tests/\n"
)
sys.stdout.write(
" 3. Regenerate Anki package: "
"python -m python_pkg.polish_license_plates.polish_license_plates_anki\n"
)
except RuntimeError as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,489 @@
"""Database of Polish car license plate registration codes.
This module contains a comprehensive mapping of Polish vehicle registration
plate codes to their corresponding locations (cities, powiats, voivodeships).
Polish license plates use a system where:
- First letter indicates the voivodeship (province)
- Following 1-2 letters indicate the specific city or powiat (county)
The database is organized by voivodeships in alphabetical order:
- B: Podlaskie
- C: Kujawsko-Pomorskie
- D: Dolnośląskie
- E: Łódzkie
- F: Lubuskie
- G: Pomorskie
- K: Małopolskie
- L: Lubelskie
- N: Warmińsko-Mazurskie
- O: Opolskie
- P: Wielkopolskie
- R: Podkarpackie
- S: Śląskie
- T: Świętokrzyskie
- W: Mazowieckie
- Z: Zachodniopomorskie
Data source:
Wikipedia - Vehicle registration plates of Poland
https://en.wikipedia.org/wiki/Vehicle_registration_plates_of_Poland
Note:
This data can be automatically updated by running:
python -m python_pkg.polish_license_plates.fetch_license_plates
Examples:
WA = Warszawa (Warsaw)
KR = Kraków
GD = Gdańsk
"""
from __future__ import annotations
LICENSE_PLATE_CODES: dict[str, str] = {
"DA": "Wrocław Fabryczna",
"DB": "Wałbrzych",
"DC": "Wrocław Śródmieście",
"DD": "Dzierżoniów",
"DE": "Wrocław Psie Pole",
"DF": "Wrocław Krzyki",
"DG": "Głogów",
"DH": "Wrocław Stare Miasto",
"DJ": "Jelenia Góra",
"DK": "Kłodzko",
"DL": "Legnica",
"DLB": "Lubań",
"DLE": "Legnica powiat",
"DMI": "Milicz",
"DN": "Wrocław Nowy Dwór",
"DO": "Oława",
"DP": "Polkowice",
"DR": "Wrocław Krzyki",
"DS": "Świdnica",
"DSR": "Środa Śląska",
"DSW": "Świebodzice",
"DT": "Twardogóra",
"DTR": "Trzebnica",
"DW": "Wałbrzych powiat",
"DWL": "Wołów",
"DWR": "Wrocław",
"DZ": "Zgorzelec",
"DZA": "Ząbkowice Śląskie",
"DZG": "Zgorzelec powiat",
"CB": "Bydgoszcz",
"CBR": "Brodnica",
"CC": "Chełmno",
"CD": "Świecie",
"CE": "Inowrocław",
"CG": "Grudziądz",
"CH": "Chojnice",
"CI": "Inowrocław powiat",
"CL": "Lipno",
"CMG": "Mogilno",
"CN": "Nakło nad Notecią",
"CR": "Radziejów",
"CT": "Toruń",
"CTR": "Toruń powiat",
"CTU": "Tuchola",
"CW": "Włocławek",
"CWA": "Wąbrzeźno",
"CWL": "Włocławek powiat",
"CZ": "Żnin",
"LB": "Biała Podlaska",
"LBI": "Biłgoraj",
"LC": "Chełm",
"LCH": "Chełm powiat",
"LHR": "Hrubieszów",
"LI": "Janów Lubelski",
"LKR": "Kraśnik",
"LKS": "Krasnystaw",
"LL": "Lublin",
"LLE": "Łęczna",
"LLU": "Łuków",
"LM": "Biała Podlaska powiat",
"LOP": "Opole Lubelskie",
"LPA": "Parczew",
"LPU": "Puławy",
"LRA": "Radzyń Podlaski",
"LRY": "Ryki",
"LSI": "Świdnik",
"LT": "Tomaszów Lubelski",
"LU": "Lublin powiat",
"LWL": "Włodawa",
"LZ": "Zamość",
"LZA": "Zamość powiat",
"FG": "Gorzów Wielkopolski",
"FKR": "Krosno Odrzańskie",
"FMI": "Międzyrzecz",
"FNW": "Nowa Sól",
"FSD": "Strzelce-Drezdenko",
"FSL": "Słubice",
"FSU": "Sulęcin",
"FSW": "Świebodzin",
"FWS": "Wschowa",
"FZ": "Zielona Góra",
"FZG": "Zielona Góra powiat",
"FZI": "Żagań",
"FZY": "Żary",
"EA": "Bełchatów",
"EB": "Łódź Bałuty",
"EBE": "Bełchatów powiat",
"EBR": "Brzeziny",
"EC": "Łęczyca",
"ED": "Łódź Śródmieście",
"EE": "Łódź Górna",
"EG": "Głowno",
"EK": "Kutno",
"EKU": "Kutno powiat",
"EL": "Łask",
"ELA": "Łowicz",
"ELE": "Łęczyca powiat",
"ELW": "Łowicz powiat",
"EM": "Opoczno",
"EO": "Opoczno powiat",
"EP": "Piotrków Trybunalski",
"EPA": "Pajęczno",
"EPD": "Poddębice",
"EPI": "Piotrków Trybunalski powiat",
"ER": "Rawa Mazowiecka",
"ERA": "Radomsko",
"ERW": "Rawa Mazowiecka powiat",
"ES": "Sieradz",
"ESI": "Sieradz powiat",
"ESK": "Skierniewice",
"ESR": "Skierniewice powiat",
"ET": "Tomaszów Mazowiecki",
"EW": "Wieluń",
"EWI": "Wieluń powiat",
"EZ": "Zduńska Wola",
"EZD": "Zgierz",
"KA": "Kraków Krowodrza",
"KB": "Bochnia",
"KBC": "Brzesko",
"KC": "Chrzanów",
"KCH": "Chrzanów powiat",
"KD": "Kraków Nowa Huta",
"KDA": "Dąbrowa Tarnowska",
"KE": "Kraków Śródmieście",
"KG": "Gorlice",
"KH": "Kraków Podgórze",
"KI": "Miechów",
"KK": "Kraków Śródmieście",
"KL": "Limanowa",
"KLI": "Limanowa powiat",
"KM": "Myślenice",
"KN": "Nowy Sącz",
"KNS": "Nowy Sącz powiat",
"KNT": "Nowy Targ",
"KO": "Olkusz",
"KOL": "Olkusz powiat",
"KOS": "Oświęcim",
"KP": "Proszowice",
"KR": "Kraków",
"KRA": "Kraków powiat",
"KS": "Sucha Beskidzka",
"KT": "Tarnów",
"KTA": "Tarnów powiat",
"KTT": "Tatry",
"KW": "Wadowice",
"KWA": "Wadowice powiat",
"WA": "Warszawa",
"WB": "Warszawa Bemowo",
"WBR": "Białobrzegi",
"WC": "Ciechanów",
"WCI": "Ciechanów powiat",
"WD": "Warszawa Praga Południe",
"WE": "Warszawa Praga Północ",
"WF": "Garwolin",
"WG": "Grodzisk Mazowiecki",
"WGM": "Grójec",
"WGO": "Gostynin",
"WGR": "Garwolin powiat",
"WH": "Warszawa Mokotów",
"WI": "Pruszków",
"WJ": "Józefów",
"WK": "Kozienice",
"WL": "Legionowo",
"WLI": "Lipsko",
"WLS": "Łosice",
"WM": "Mińsk Mazowiecki",
"WMA": "Maków Mazowiecki",
"WML": "Mława",
"WN": "Warszawa Białołęka",
"WND": "Nowy Dwór Mazowiecki",
"WO": "Otwock",
"WOR": "Ostrołęka",
"WOS": "Ostrów Mazowiecka",
"WOT": "Otwock powiat",
"WP": "Piaseczno",
"WPI": "Płońsk",
"WPL": "Płock",
"WPN": "Przasnysz",
"WPR": "Przysucha",
"WPU": "Pułtusk",
"WPY": "Płońsk powiat",
"WPZ": "Przasnysz powiat",
"WR": "Radom",
"WRA": "Radom powiat",
"WS": "Siedlce",
"WSC": "Sokołów Podlaski",
"WSE": "Siedlce powiat",
"WSI": "Sierpc",
"WSK": "Sochaczew",
"WSZ": "Szydłowiec",
"WT": "Warszawa Wawer",
"WU": "Warszawa Ursus",
"WV": "Ostrołęka powiat",
"WW": "Warszawa Ochota",
"WWL": "Wołomin",
"WWY": "Wyszków",
"WX": "Warszawa Ursynów",
"WY": "Warszawa Wola",
"WZ": "Żyrardów",
"WZW": "Zwoleń",
"OA": "Brzeg",
"OB": "Namysłów",
"OGL": "Głubczyce",
"OK": "Kędzierzyn-Koźle",
"OKL": "Kluczbork",
"OKR": "Krapkowice",
"OL": "Nysa",
"ONA": "Namysłów powiat",
"ONY": "Nysa powiat",
"OP": "Opole",
"OO": "Opole powiat",
"OOL": "Olesno",
"OPO": "Prudnik",
"OST": "Strzelce Opolskie",
"RB": "Brzozów",
"RBI": "Biłgoraj",
"RC": "Rzeszów Centrum",
"RD": "Dębica",
"RDE": "Dębica powiat",
"RJ": "Jarosław",
"RJA": "Jarosław powiat",
"RJS": "Jasło",
"RK": "Krosno",
"RKL": "Kolbuszowa",
"RKR": "Krosno powiat",
"RL": "Leżajsk",
"RLE": "Lesko",
"RLS": "Lubaczów",
"RLU": "Łańcut",
"RM": "Mielec",
"RMI": "Mielec powiat",
"RN": "Nisko",
"RP": "Przemyśl",
"RPR": "Przemyśl powiat",
"RPZ": "Przeworsk",
"RR": "Rzeszów",
"RRS": "Ropczyce-Sędziszów",
"RRZ": "Rzeszów powiat",
"RSA": "Sanok",
"RSN": "Sanok powiat",
"RSR": "Stalowa Wola",
"RST": "Strzyżów",
"RTA": "Tarnobrzeg",
"RZ": "Rzeszów",
"BA": "Augustów",
"BBI": "Białystok",
"BC": "Hajnówka",
"BD": "Bielsk Podlaski",
"BE": "Wysokie Mazowieckie",
"BG": "Grajewo",
"BGR": "Grajewo powiat",
"BH": "Hajnówka powiat",
"BHA": "Hajnówka",
"BI": "Białystok",
"BIA": "Białystok powiat",
"BJ": "Kolno",
"BK": "Kolno powiat",
"BKL": "Kolno",
"BL": "Łomża",
"BLM": "Łomża powiat",
"BLS": "Łomża",
"BM": "Mońki",
"BMN": "Mońki powiat",
"BO": "Sokółka",
"BP": "Zambrów",
"BPI": "Piątnica",
"BR": "Siemiatycze",
"BS": "Sokółka powiat",
"BSE": "Sejny",
"BSI": "Siemiatycze powiat",
"BSK": "Sokółka",
"BSU": "Suwałki",
"BT": "Suwałki powiat",
"BWM": "Wysokie Mazowieckie powiat",
"BZA": "Zambrów powiat",
"GA": "Gdańsk",
"GB": "Bytów",
"GBY": "Bytów powiat",
"GC": "Chojnice",
"GCH": "Chojnice powiat",
"GCZ": "Człuchów",
"GD": "Gdańsk",
"GDA": "Gdańsk powiat",
"GDY": "Gdynia",
"GI": "Kościerzyna",
"GKA": "Kartuzy",
"GKS": "Kościerzyna powiat",
"GKW": "Kwidzyn",
"GL": "Lębork",
"GLE": "Lębork powiat",
"GMB": "Malbork",
"GND": "Nowy Dwór Gdański",
"GP": "Puck",
"GPU": "Puck powiat",
"GS": "Słupsk",
"GSL": "Słupsk powiat",
"GSP": "Starogard Gdański",
"GST": "Sztum",
"GT": "Tczew",
"GTB": "Tczew powiat",
"GW": "Wejherowo",
"GWE": "Wejherowo powiat",
"SA": "Sosnowiec",
"SB": "Bielsko-Biała",
"SBB": "Bielsko-Biała powiat",
"SBE": "Będzin",
"SBI": "Bieruń-Lędziny",
"SC": "Chorzów",
"SCH": "Cieszyn",
"SCI": "Cieszyn powiat",
"SD": "Dąbrowa Górnicza",
"SF": "Racibórz",
"SG": "Gliwice",
"SGI": "Gliwice powiat",
"SH": "Chorzów",
"SI": "Siemianowice Śląskie",
"SJ": "Jastrzębie-Zdrój",
"SJZ": "Jastrzębie-Zdrój",
"SK": "Katowice",
"SKA": "Katowice powiat",
"SKL": "Kłobuck",
"SKT": "Lubliniec",
"SL": "Rybnik",
"SLU": "Lubliniec powiat",
"SM": "Mysłowice",
"SMI": "Mikołów",
"SML": "Myszków",
"SN": "Nowy Targ",
"SO": "Sosnowiec powiat",
"SP": "Piekary Śląskie",
"SPI": "Pszczyna",
"SPS": "Pszczyna powiat",
"SR": "Rybnik powiat",
"SRC": "Racibórz powiat",
"SRY": "Rybnik",
"SS": "Świętochłowice",
"ST": "Tychy",
"STA": "Tarnowskie Góry",
"STG": "Tarnowskie Góry powiat",
"SW": "Wodzisław Śląski",
"SWD": "Wodzisław Śląski powiat",
"SY": "Ruda Śląska",
"SZ": "Zabrze",
"SZA": "Zawiercie",
"SZO": "Żory",
"SZY": "Żywiec",
"TB": "Busko-Zdrój",
"TBU": "Busko-Zdrój powiat",
"TJE": "Jędrzejów",
"TK": "Kielce",
"TKA": "Kazimierza Wielka",
"TKI": "Kielce powiat",
"TKN": "Końskie",
"TKO": "Końskie powiat",
"TOS": "Ostrołęka",
"TPI": "Pińczów",
"TSA": "Sandomierz",
"TSK": "Skarżysko-Kamienna",
"TST": "Starachowice",
"TWL": "Włoszczowa",
"NBA": "Bartoszyce",
"NBR": "Braniewo",
"NDZ": "Działdowo",
"NE": "Elbląg",
"NEL": "Elbląg powiat",
"NEB": "Ełk",
"NEK": "Ełk powiat",
"NG": "Giżycko",
"NGI": "Giżycko powiat",
"NGO": "Gołdap",
"NI": "Iława",
"NKE": "Kętrzyn",
"NL": "Lidzbark Warmiński",
"NMR": "Mrągowo",
"NNI": "Nidzica",
"NO": "Olsztyn",
"NOE": "Olecko",
"NOL": "Olsztyn powiat",
"NOS": "Ostróda",
"NPI": "Pisz",
"NSZ": "Szczytno",
"NW": "Węgorzewo",
"PCD": "Czarnków-Trzcianka",
"PCH": "Chodzież",
"PGN": "Gniezno",
"PGO": "Gostyń",
"PGR": "Grodzisk Wielkopolski",
"PIA": "Piła",
"PJ": "Jarocin",
"PJA": "Jarocin powiat",
"PK": "Kępno",
"PKA": "Kalisz",
"PKL": "Kalisz powiat",
"PKN": "Koło",
"PKO": "Konin",
"PKS": "Kościan",
"PL": "Leszno",
"PLE": "Leszno powiat",
"PMI": "Międzychód",
"PNT": "Nowy Tomyśl",
"PO": "Poznań",
"POB": "Oborniki",
"POL": "Ostrów Wielkopolski",
"POP": "Opole",
"POS": "Ostrzeszów",
"POT": "Ostrów Wielkopolski powiat",
"PP": "Pleszew",
"PPI": "Piła powiat",
"PPL": "Pleszew powiat",
"PRA": "Poznań powiat",
"PRS": "Rawicz",
"PSE": "Śrem",
"PSL": "Słupca",
"PSR": "Środa Wielkopolska",
"PSZ": "Szamotuły",
"PT": "Turek",
"PTU": "Turek powiat",
"PW": "Wągrowiec",
"PWA": "Wągrowiec powiat",
"PWL": "Wolsztyn",
"PWR": "Września",
"PZ": "Poznań",
"PZL": "Złotów",
"ZBI": "Białogard",
"ZCH": "Choszczno",
"ZG": "Gryfice",
"ZGR": "Gryfino",
"ZI": "Stargard",
"ZK": "Kołobrzeg",
"ZKA": "Kamień Pomorski",
"ZKL": "Kołobrzeg powiat",
"ZKO": "Koszalin",
"ZKS": "Koszalin powiat",
"ZL": "Łobez",
"ZM": "Myślibórz",
"ZPL": "Pyrzyce",
"ZPO": "Police",
"ZS": "Szczecin",
"ZSL": "Sławno",
"ZST": "Stargard powiat",
"ZSW": "Świnoujście",
"ZSZ": "Szczecinek",
"ZW": "Wałcz",
"ZZ": "Szczecin powiat",
}

View File

@ -0,0 +1,242 @@
"""Anki flashcard generator for Polish car license plates.
Generates Anki-compatible flashcard decks with bidirectional cards for Polish
vehicle registration plate codes and their corresponding locations.
Creates two types of cards:
1. Code Location (e.g., WY Warszawa Wola)
2. Location Code (e.g., Warszawa Wola WY)
Usage:
# Generate Anki cards for all Polish license plates
python -m python_pkg.polish_license_plates.polish_license_plates_anki
# Specify custom output file
python -m python_pkg.polish_license_plates.polish_license_plates_anki \
--output plates.apkg
Output:
Creates a self-contained .apkg file that can be directly imported into Anki.
"""
from __future__ import annotations
import argparse
import hashlib
from pathlib import Path
import random
import sys
from typing import TYPE_CHECKING
import genanki
from python_pkg.polish_license_plates.license_plate_data import LICENSE_PLATE_CODES
if TYPE_CHECKING:
from collections.abc import Sequence
def generate_anki_package(
deck_name: str = "Polish License Plates",
) -> genanki.Package:
"""Generate Anki package (.apkg) for Polish license plates.
Creates two cards for each license plate code:
1. Code Location
2. Location Code
Args:
deck_name: Name for the Anki deck.
Returns:
genanki.Package object ready to be written to file.
"""
# Create unique model ID based on deck name
model_id_hash = hashlib.md5(
f"polish_license_plates_{deck_name}".encode(),
usedforsecurity=False,
)
model_id = int(model_id_hash.hexdigest()[:8], 16)
# Define the note model with centered styling and bidirectional templates
card_css = """
.card {
font-family: Arial, sans-serif;
font-size: 28px;
text-align: center;
color: #333;
background-color: #fff;
}
.card.night_mode {
color: #eee;
background-color: #2f2f2f;
}
.question {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
font-size: 48px;
font-weight: bold;
color: #2C3E50;
}
.card.night_mode .question {
color: #ECF0F1;
}
.answer {
font-size: 36px;
font-weight: bold;
margin-top: 20px;
color: #27AE60;
}
.card.night_mode .answer {
color: #2ECC71;
}
.plate-code {
font-family: 'Courier New', monospace;
background-color: #FFD700;
color: #000;
padding: 15px 30px;
border: 3px solid #000;
border-radius: 8px;
display: inline-block;
letter-spacing: 5px;
}
.card.night_mode .plate-code {
background-color: #FFA500;
}
"""
my_model = genanki.Model(
model_id,
"Polish License Plate Model",
fields=[
{"name": "Code"},
{"name": "Location"},
],
templates=[
{
"name": "Code → Location",
"qfmt": '<div class="question">'
'<span class="plate-code">{{Code}}</span>'
"</div>",
"afmt": '<div class="question">'
'<span class="plate-code">{{Code}}</span>'
"</div>"
'<hr id="answer">'
'<div class="answer">{{Location}}</div>',
},
{
"name": "Location → Code",
"qfmt": '<div class="question">{{Location}}</div>',
"afmt": '<div class="question">{{Location}}</div>'
'<hr id="answer">'
'<div class="answer">'
'<span class="plate-code">{{Code}}</span>'
"</div>",
},
],
css=card_css,
)
# Create unique deck ID
deck_id = random.randrange(1 << 30, 1 << 31) # noqa: S311
# Create the deck
my_deck = genanki.Deck(deck_id, deck_name)
# Generate notes for each license plate code
for code, location in sorted(LICENSE_PLATE_CODES.items()):
note = genanki.Note(
model=my_model,
fields=[code, location],
tags=["geography", "poland", "license-plates", "transportation"],
)
my_deck.add_note(note)
# Create package
return genanki.Package(my_deck)
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 Polish license plates.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--output",
"-o",
type=str,
default=None,
help="Output file path (default: polish_license_plates.apkg)",
)
parser.add_argument(
"--deck-name",
"-d",
type=str,
default="Polish License Plates",
help="Name for the Anki deck (default: 'Polish License Plates')",
)
args = parser.parse_args(argv)
# Determine output path
output_path = (
Path(args.output) if args.output else Path("polish_license_plates.apkg")
)
try:
num_codes = len(LICENSE_PLATE_CODES)
num_cards = num_codes * 2 # Two cards per code (bidirectional)
sys.stdout.write(
f"Generating flashcards for {num_codes} Polish license plate codes...\n"
)
sys.stdout.write(
"Each code will have 2 cards: Code → Location and Location → Code\n"
)
# Generate the package
package = generate_anki_package(args.deck_name)
# Write to file
package.write_to_file(str(output_path))
sys.stdout.write("\n")
sys.stdout.write("=" * 70 + "\n")
sys.stdout.write("FLASHCARD GENERATION COMPLETE\n")
sys.stdout.write("=" * 70 + "\n")
sys.stdout.write(f"License plate codes: {num_codes}\n")
sys.stdout.write(f"Total flashcards: {num_cards} (bidirectional)\n")
sys.stdout.write(f"Output file: {output_path.absolute()}\n")
sys.stdout.write("\n")
sys.stdout.write("Card types:\n")
sys.stdout.write(" 1. Code → Location (e.g., WY → Warszawa Wola)\n")
sys.stdout.write(" 2. Location → Code (e.g., Warszawa Wola → WY)\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("You can now learn Polish license plates both ways!\n")
except (OSError, ValueError) as e:
sys.stderr.write(f"Error: {e}\n")
return 1
else:
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1 @@
"""Tests init file."""

View File

@ -0,0 +1,231 @@
"""Tests for the Polish license plates Anki generator."""
from __future__ import annotations
from pathlib import Path
import pytest
try:
from python_pkg.polish_license_plates.license_plate_data import (
LICENSE_PLATE_CODES,
)
from python_pkg.polish_license_plates.polish_license_plates_anki import (
generate_anki_package,
main,
)
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from python_pkg.polish_license_plates.license_plate_data import (
LICENSE_PLATE_CODES,
)
from python_pkg.polish_license_plates.polish_license_plates_anki import (
generate_anki_package,
main,
)
class TestLicensePlateData:
"""Tests for license plate data."""
def test_has_codes(self) -> None:
"""Test that we have license plate codes."""
assert len(LICENSE_PLATE_CODES) > 0
def test_all_codes_are_uppercase(self) -> None:
"""Test that all codes are uppercase strings."""
for code in LICENSE_PLATE_CODES:
assert isinstance(code, str)
assert code.isupper()
assert len(code) >= 2
def test_all_locations_are_strings(self) -> None:
"""Test that all locations are non-empty strings."""
for location in LICENSE_PLATE_CODES.values():
assert isinstance(location, str)
assert len(location) > 0
def test_no_duplicate_codes(self) -> None:
"""Test that all codes are unique."""
codes = list(LICENSE_PLATE_CODES.keys())
assert len(codes) == len(set(codes))
def test_warsaw_codes_present(self) -> None:
"""Test that Warsaw codes are in the database."""
warsaw_codes = [
"WA",
"WB",
"WC",
"WD",
"WE",
"WF",
"WG",
"WH",
"WI",
"WJ",
"WK",
"WL",
"WM",
"WN",
"WO",
"WP",
"WR",
"WS",
"WT",
"WU",
"WW",
"WX",
"WY",
"WZ",
]
for code in warsaw_codes:
assert code in LICENSE_PLATE_CODES
def test_major_cities_present(self) -> None:
"""Test that major Polish cities have codes."""
major_cities = {
"WA": "Warszawa",
"KR": "Kraków",
"GD": "Gdańsk",
"PO": "Poznań",
"WR": "Radom",
"BI": "Białystok",
}
for code, city_part in major_cities.items():
assert code in LICENSE_PLATE_CODES
assert city_part.lower() in LICENSE_PLATE_CODES[code].lower()
def test_voivodeship_prefixes_present(self) -> None:
"""Test that all 16 voivodeship prefixes are represented."""
voivodeship_prefixes = {
"B",
"C",
"D",
"E",
"F",
"G",
"K",
"L",
"N",
"O",
"P",
"R",
"S",
"T",
"W",
"Z",
}
found_prefixes = {code[0] for code in LICENSE_PLATE_CODES}
assert voivodeship_prefixes.issubset(found_prefixes)
class TestGenerateAnkiPackage:
"""Tests for generating Anki package."""
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
def test_generates_notes_for_all_codes(self) -> None:
"""Test that package contains notes for all license plate codes."""
package = generate_anki_package()
deck = package.decks[0]
# Each code generates one note with two card templates
assert len(deck.notes) == len(LICENSE_PLATE_CODES)
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"
def test_notes_have_correct_fields(self) -> None:
"""Test that notes have Code and Location fields."""
package = generate_anki_package()
deck = package.decks[0]
note = deck.notes[0]
# Note should have 2 fields: Code and Location
assert len(note.fields) == 2
# Fields should be non-empty strings
assert len(note.fields[0]) > 0
assert len(note.fields[1]) > 0
def test_notes_have_tags(self) -> None:
"""Test that notes have appropriate tags."""
package = generate_anki_package()
deck = package.decks[0]
note = deck.notes[0]
assert "geography" in note.tags
assert "poland" in note.tags
assert "license-plates" in note.tags
def test_model_has_bidirectional_templates(self) -> None:
"""Test that the model has two card templates (bidirectional)."""
package = generate_anki_package()
deck = package.decks[0]
model = deck.notes[0].model
# Should have 2 templates: Code → Location and Location → Code
assert len(model.templates) == 2
class TestMain:
"""Tests for the main CLI function."""
def test_creates_output_file(self, tmp_path: Path) -> None:
"""Test that main creates the output file."""
output_file = tmp_path / "test_output.apkg"
result = main(
[
"--output",
str(output_file),
]
)
assert result == 0
assert output_file.exists()
def test_custom_deck_name(self, tmp_path: Path) -> None:
"""Test that custom deck name is used."""
output_file = tmp_path / "test_output.apkg"
result = main(
[
"--output",
str(output_file),
"--deck-name",
"Custom Deck",
]
)
assert result == 0
assert output_file.exists()
def test_default_output_path(self) -> None:
"""Test that default output path is used when not specified."""
# Clean up any existing file
default_path = Path("polish_license_plates.apkg")
if default_path.exists():
default_path.unlink()
result = main([])
assert result == 0
assert default_path.exists()
# Clean up
default_path.unlink()
def test_help_flag(self) -> None:
"""Test that --help works."""
with pytest.raises(SystemExit) as exc_info:
main(["--help"])
assert exc_info.value.code == 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])