Source code for pyworldatlas.models.learning

"""Learning-tool models: flashcards, quiz questions, and discovery cards."""

from __future__ import annotations

from dataclasses import dataclass
import json
from typing import Any

from ._serialize import _jsonable
from ._display import friendly_repr
from .reference import Currency, LocalizedName
from .geography import Coordinate, ElevationPoint
from .places import Place
from .results import CountryReference


[docs] @dataclass(frozen=True, slots=True) class Flashcard: """A deterministic geography study prompt and answer. Flashcards contain no scoring, session state, or hidden random state. The ``topic`` value identifies the documented generator used by :meth:`pyworldatlas.Atlas.flashcards`. """ topic: str prompt: str answer: str country: CountryReference def __str__(self) -> str: return f"{self.prompt} {self.answer}" def __repr__(self) -> str: return friendly_repr(self, f"topic={self.topic!r}, prompt={self.prompt!r}")
[docs] def to_dict(self) -> dict[str, Any]: """Return JSON-compatible primitives for this flashcard.""" data: dict[str, Any] = _jsonable(self) return data
[docs] def to_json(self, indent: int | None = None) -> str: """Serialize this flashcard as JSON without escaping Unicode text.""" return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
[docs] @dataclass(frozen=True, slots=True) class PlaceQuestion: """A deterministic multiple-choice question about famous places. ``places`` holds the places the question is about, so a learner can look up more after answering. Choices are in display order and ``answer`` is always one of them. """ topic: str prompt: str choices: tuple[str, ...] answer: str places: tuple[Place, ...] def __str__(self) -> str: options = " ".join(f"{number}) {text}" for number, text in enumerate(self.choices, 1)) return f"{self.prompt} {options}" def __repr__(self) -> str: return friendly_repr(self, f"topic={self.topic!r}, prompt={self.prompt!r}") def __post_init__(self) -> None: if len(self.choices) < 2 or len(set(self.choices)) != len(self.choices): raise ValueError("a question needs at least two different choices") if self.answer not in self.choices: raise ValueError("answer must be present in choices") @property def answer_number(self) -> int: """Return the correct choice number, counting from one.""" return self.choices.index(self.answer) + 1
[docs] def is_correct(self, response: str | int) -> bool: """Check a displayed answer (case-insensitive) or a one-based choice number.""" if isinstance(response, bool): return False if isinstance(response, int): return response == self.answer_number return response.strip().casefold() == self.answer.casefold()
[docs] def to_dict(self) -> dict[str, Any]: """Return JSON-compatible primitives for this question.""" data: dict[str, Any] = _jsonable(self) return data
[docs] def to_json(self, indent: int | None = None) -> str: """Serialize this question as JSON without escaping Unicode text.""" return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
[docs] @dataclass(frozen=True, slots=True) class QuizQuestion: """A deterministic multiple-choice geography question. Choices are stored in display order and ``answer`` is always one of them. Use :attr:`answer_number` for a one-based classroom answer key or :meth:`is_correct` to check either a displayed answer or choice number. """ topic: str prompt: str choices: tuple[str, ...] answer: str country: CountryReference def __str__(self) -> str: options = " ".join(f"{number}) {text}" for number, text in enumerate(self.choices, 1)) return f"{self.prompt} {options}" def __repr__(self) -> str: return friendly_repr(self, f"topic={self.topic!r}, prompt={self.prompt!r}") def __post_init__(self) -> None: if len(self.choices) < 2: raise ValueError("a quiz question must contain at least two choices") normalized = tuple(choice.casefold() for choice in self.choices) if len(set(normalized)) != len(normalized): raise ValueError("quiz choices must be unique") if self.answer not in self.choices: raise ValueError("answer must be present in choices") @property def answer_number(self) -> int: """Return the correct choice number using one-based classroom numbering.""" return self.choices.index(self.answer) + 1
[docs] def is_correct(self, choice: str | int) -> bool: """Check an answer string or a one-based choice number. Text matching ignores surrounding whitespace and letter case. Invalid choice numbers return ``False``; unsupported input types raise :class:`TypeError`. """ if isinstance(choice, bool): raise TypeError("choice must be an answer string or one-based integer") if isinstance(choice, int): return 1 <= choice <= len(self.choices) and self.choices[choice - 1] == self.answer if isinstance(choice, str): return choice.strip().casefold() == self.answer.strip().casefold() raise TypeError("choice must be an answer string or one-based integer")
[docs] def to_dict(self) -> dict[str, Any]: """Return JSON-compatible primitives for this question.""" data: dict[str, Any] = _jsonable(self) return data
[docs] def to_json(self, indent: int | None = None) -> str: """Serialize this question without escaping Unicode text.""" return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
[docs] @dataclass(frozen=True, slots=True) class CountryDiscoveryCard: """A compact, serializable teaching view of one materialized country. Every value is copied from, or calculated directly from, an existing :class:`Country`. Creating a card never queries the database or network. """ country: CountryReference flag_emoji: str | None official_name: str | None formal_name: str | None capital: str | None capital_coordinates: Coordinate | None continent: str | None region: str | None subregion: str | None population: int | None area_km2: float | None population_density: float | None currency: Currency | None language_codes: tuple[str, ...] calling_codes: tuple[str, ...] top_level_domain: str | None observed_timezones: tuple[str, ...] local_names: tuple[LocalizedName, ...] major_city_count: int source_ids: tuple[str, ...] anthem_title: str | None = None motto_text: str | None = None demonym: str | None = None timezone_ids: tuple[str, ...] = () coastline_km: float | None = None highest_point: ElevationPoint | None = None climate_zone_codes: tuple[str, ...] = () def __str__(self) -> str: return self.country.name def __repr__(self) -> str: return friendly_repr(self, f"{self.country.name!r}")
[docs] def to_dict(self) -> dict[str, Any]: """Return JSON-compatible primitives for this discovery card.""" data: dict[str, Any] = _jsonable(self) return data
[docs] def to_json(self, indent: int | None = None) -> str: """Serialize this discovery card as JSON without escaping Unicode text.""" return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)