167 lines
5.2 KiB
Python
167 lines
5.2 KiB
Python
import csv
|
|||
|
|
import logging
|
||
|
|
import unicodedata
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Optional
|
||
|
|
|
||
|
|
from models import WordClue
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
MIN_WORD_LENGTH = 3
|
||
|
|
REQUIRED_COLUMNS = {"word", "clue"}
|
||
|
|
OPTIONAL_COLUMNS = {"difficulty", "category"}
|
||
|
|
|
||
|
|
|
||
|
|
class CSVValidationError(Exception):
|
||
|
|
"""Raised when the CSV file fails validation."""
|
||
|
|
|
||
|
|
|
||
|
|
def _strip_accents(text: str) -> str:
|
||
|
|
"""Replace accented characters with their ASCII equivalents."""
|
||
|
|
nfkd = unicodedata.normalize("NFKD", text)
|
||
|
|
return "".join(c for c in nfkd if not unicodedata.combining(c))
|
||
|
|
|
||
|
|
|
||
|
|
def _normalize_word(raw: str) -> Optional[str]:
|
||
|
|
"""Normalize a word to uppercase A-Z only. Returns None if invalid."""
|
||
|
|
word = _strip_accents(raw.strip()).upper()
|
||
|
|
word = word.replace(" ", "").replace("-", "")
|
||
|
|
if not word.isalpha():
|
||
|
|
return None
|
||
|
|
return word
|
||
|
|
|
||
|
|
|
||
|
|
def read_csv(
|
||
|
|
filepath: str | Path,
|
||
|
|
min_words: int = 1,
|
||
|
|
max_word_length: Optional[int] = None,
|
||
|
|
) -> list[WordClue]:
|
||
|
|
"""Read and validate a CSV file of words and clues.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
filepath: Path to the CSV file.
|
||
|
|
min_words: Minimum number of valid words required.
|
||
|
|
max_word_length: If set, words longer than this trigger a warning.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
A deduplicated, validated list of WordClue objects.
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
CSVValidationError: If the file is missing required columns or
|
||
|
|
contains fewer than min_words valid entries.
|
||
|
|
FileNotFoundError: If the file does not exist.
|
||
|
|
"""
|
||
|
|
path = Path(filepath)
|
||
|
|
if not path.exists():
|
||
|
|
raise FileNotFoundError(f"CSV file not found: {path}")
|
||
|
|
|
||
|
|
with open(path, newline="", encoding="utf-8") as f:
|
||
|
|
reader = csv.DictReader(f)
|
||
|
|
|
||
|
|
if reader.fieldnames is None:
|
||
|
|
raise CSVValidationError("CSV file is empty or has no header row.")
|
||
|
|
|
||
|
|
headers = {h.strip().lower() for h in reader.fieldnames}
|
||
|
|
missing = REQUIRED_COLUMNS - headers
|
||
|
|
if missing:
|
||
|
|
raise CSVValidationError(
|
||
|
|
f"CSV is missing required columns: {', '.join(sorted(missing))}. "
|
||
|
|
f"Found: {', '.join(sorted(headers))}"
|
||
|
|
)
|
||
|
|
|
||
|
|
has_difficulty = "difficulty" in headers
|
||
|
|
has_category = "category" in headers
|
||
|
|
|
||
|
|
words_seen: set[str] = set()
|
||
|
|
results: list[WordClue] = []
|
||
|
|
skipped = 0
|
||
|
|
|
||
|
|
for line_num, row in enumerate(reader, start=2):
|
||
|
|
raw_word = row.get("word", "").strip()
|
||
|
|
clue = row.get("clue", "").strip()
|
||
|
|
|
||
|
|
if not raw_word or not clue:
|
||
|
|
logger.warning("Line %d: empty word or clue, skipping.", line_num)
|
||
|
|
skipped += 1
|
||
|
|
continue
|
||
|
|
|
||
|
|
word = _normalize_word(raw_word)
|
||
|
|
if word is None:
|
||
|
|
logger.warning(
|
||
|
|
"Line %d: word %r contains non-alpha characters, skipping.",
|
||
|
|
line_num,
|
||
|
|
raw_word,
|
||
|
|
)
|
||
|
|
skipped += 1
|
||
|
|
continue
|
||
|
|
|
||
|
|
if len(word) < MIN_WORD_LENGTH:
|
||
|
|
logger.warning(
|
||
|
|
"Line %d: word %r is too short (min %d letters), skipping.",
|
||
|
|
line_num,
|
||
|
|
word,
|
||
|
|
MIN_WORD_LENGTH,
|
||
|
|
)
|
||
|
|
skipped += 1
|
||
|
|
continue
|
||
|
|
|
||
|
|
if max_word_length and len(word) > max_word_length:
|
||
|
|
logger.warning(
|
||
|
|
"Line %d: word %r has %d letters, exceeds grid size of %d.",
|
||
|
|
line_num,
|
||
|
|
word,
|
||
|
|
len(word),
|
||
|
|
max_word_length,
|
||
|
|
)
|
||
|
|
|
||
|
|
if word in words_seen:
|
||
|
|
logger.warning(
|
||
|
|
"Line %d: duplicate word %r, skipping.", line_num, word
|
||
|
|
)
|
||
|
|
skipped += 1
|
||
|
|
continue
|
||
|
|
|
||
|
|
words_seen.add(word)
|
||
|
|
|
||
|
|
difficulty = row.get("difficulty", "").strip().lower() or None if has_difficulty else None
|
||
|
|
category = row.get("category", "").strip() or None if has_category else None
|
||
|
|
|
||
|
|
results.append(WordClue(
|
||
|
|
word=word,
|
||
|
|
clue=clue,
|
||
|
|
difficulty=difficulty,
|
||
|
|
category=category,
|
||
|
|
))
|
||
|
|
|
||
|
|
if len(results) < min_words:
|
||
|
|
raise CSVValidationError(
|
||
|
|
f"CSV contains only {len(results)} valid word(s), "
|
||
|
|
f"but at least {min_words} are required."
|
||
|
|
)
|
||
|
|
|
||
|
|
if skipped:
|
||
|
|
logger.info("Skipped %d invalid row(s). Loaded %d word(s).", skipped, len(results))
|
||
|
|
else:
|
||
|
|
logger.info("Loaded %d word(s) from %s.", len(results), path.name)
|
||
|
|
|
||
|
|
return results
|
||
|
|
|
||
|
|
|
||
|
|
def group_by_category(words: list[WordClue]) -> dict[str, list[WordClue]]:
|
||
|
|
"""Group words by their category. Uncategorized words go under 'General'."""
|
||
|
|
groups: dict[str, list[WordClue]] = {}
|
||
|
|
for wc in words:
|
||
|
|
key = wc.category or "General"
|
||
|
|
groups.setdefault(key, []).append(wc)
|
||
|
|
return groups
|
||
|
|
|
||
|
|
|
||
|
|
def group_by_difficulty(words: list[WordClue]) -> dict[str, list[WordClue]]:
|
||
|
|
"""Group words by difficulty. Words without difficulty go under 'medium'."""
|
||
|
|
groups: dict[str, list[WordClue]] = {}
|
||
|
|
for wc in words:
|
||
|
|
key = wc.difficulty or "medium"
|
||
|
|
groups.setdefault(key, []).append(wc)
|
||
|
|
return groups
|