MIL-005: Step 4 - Save progress
Review record RC-027 (Go, autonomous mode, not independent). Task: MIL-005#1 Task: MIL-005#2 Task: MIL-005#3 Task: MIL-005#4 Task: MIL-005#5
This commit is contained in:
@@ -9,19 +9,25 @@ from collections.abc import Callable, Sequence
|
||||
from flash_card.application.use_cases.study_session import StudySession
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.domain.repositories.progress_repository import ProgressRepository
|
||||
from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository
|
||||
|
||||
|
||||
def open_study_session(
|
||||
vocabulary_repository: VocabularyRepository,
|
||||
progress_repository: ProgressRepository,
|
||||
choose: Callable[[Sequence[Card]], Card],
|
||||
) -> StudySession:
|
||||
"""Load the word list and build a session over all its cards.
|
||||
"""Build a session over the saved words to learn, else the whole word list.
|
||||
|
||||
@param vocabulary_repository Source of the word list.
|
||||
@param vocabulary_repository Source of the delivered word list.
|
||||
@param progress_repository Store of the words still to learn.
|
||||
@param choose Picks one card from a non-empty sequence.
|
||||
@return The new session.
|
||||
@throws VocabularyError When the word list cannot be loaded.
|
||||
@throws VocabularyError When the saved progress, or the word list when no
|
||||
progress exists, cannot be loaded.
|
||||
"""
|
||||
deck = Deck.from_vocabulary(vocabulary_repository.load())
|
||||
return StudySession(deck, choose)
|
||||
deck = progress_repository.load()
|
||||
if deck is None:
|
||||
deck = Deck.from_vocabulary(vocabulary_repository.load())
|
||||
return StudySession(deck, choose, progress_repository)
|
||||
|
||||
@@ -1,66 +1,88 @@
|
||||
"""One study session.
|
||||
|
||||
@file study_session.py
|
||||
@brief The StudySession use case: draws the cards the user studies.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from flash_card.application.dto.card_dto import CardDto
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
|
||||
|
||||
class StudySession:
|
||||
"""Draws cards from a deck, one after another."""
|
||||
|
||||
def __init__(self, deck: Deck, choose: Callable[[Sequence[Card]], Card]) -> None:
|
||||
"""Create a session.
|
||||
|
||||
@param deck The cards still to learn.
|
||||
@param choose Picks one card from a non-empty sequence.
|
||||
"""
|
||||
## @brief The cards still to learn.
|
||||
self._deck = deck
|
||||
## @brief Picks one card from a non-empty sequence.
|
||||
self._choose = choose
|
||||
## @brief The card shown now, or None.
|
||||
self._current: Card | None = None
|
||||
|
||||
@property
|
||||
def current_card(self) -> CardDto | None:
|
||||
"""Return the card shown now.
|
||||
|
||||
@return The card, or None before the first draw or when none is left.
|
||||
"""
|
||||
return self._to_dto(self._current)
|
||||
|
||||
@property
|
||||
def remaining(self) -> int:
|
||||
"""Return the number of cards still to learn.
|
||||
|
||||
@return The card count.
|
||||
"""
|
||||
return len(self._deck)
|
||||
|
||||
def next_card(self) -> CardDto | None:
|
||||
"""Draw the next card, different from the current one when possible.
|
||||
|
||||
@return The new card, or None when the deck is empty.
|
||||
"""
|
||||
if self._deck.is_empty:
|
||||
self._current = None
|
||||
return None
|
||||
self._current = self._deck.draw(self._choose, exclude=self._current)
|
||||
return self._to_dto(self._current)
|
||||
|
||||
def _to_dto(self, card: Card | None) -> CardDto | None:
|
||||
"""Map a card to its DTO.
|
||||
|
||||
@param card The card, or None.
|
||||
@return The DTO, or None when there is no card.
|
||||
"""
|
||||
if card is None:
|
||||
return None
|
||||
pair = self._deck.language_pair
|
||||
return CardDto(card.front, card.back, pair.front_code, pair.back_code)
|
||||
"""One study session.
|
||||
|
||||
@file study_session.py
|
||||
@brief The StudySession use case: draws the cards the user studies.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from flash_card.application.dto.card_dto import CardDto
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.domain.repositories.progress_repository import ProgressRepository
|
||||
|
||||
|
||||
class StudySession:
|
||||
"""Draws cards from a deck, one after another."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
deck: Deck,
|
||||
choose: Callable[[Sequence[Card]], Card],
|
||||
progress: ProgressRepository,
|
||||
) -> None:
|
||||
"""Create a session.
|
||||
|
||||
@param deck The cards still to learn.
|
||||
@param choose Picks one card from a non-empty sequence.
|
||||
@param progress Store of the words still to learn.
|
||||
"""
|
||||
## @brief Store of the words still to learn.
|
||||
self._progress = progress
|
||||
## @brief The cards still to learn.
|
||||
self._deck = deck
|
||||
## @brief Picks one card from a non-empty sequence.
|
||||
self._choose = choose
|
||||
## @brief The card shown now, or None.
|
||||
self._current: Card | None = None
|
||||
|
||||
@property
|
||||
def current_card(self) -> CardDto | None:
|
||||
"""Return the card shown now.
|
||||
|
||||
@return The card, or None before the first draw or when none is left.
|
||||
"""
|
||||
return self._to_dto(self._current)
|
||||
|
||||
@property
|
||||
def remaining(self) -> int:
|
||||
"""Return the number of cards still to learn.
|
||||
|
||||
@return The card count.
|
||||
"""
|
||||
return len(self._deck)
|
||||
|
||||
def next_card(self) -> CardDto | None:
|
||||
"""Draw the next card, different from the current one when possible.
|
||||
|
||||
@return The new card, or None when the deck is empty.
|
||||
"""
|
||||
if self._deck.is_empty:
|
||||
self._current = None
|
||||
return None
|
||||
self._current = self._deck.draw(self._choose, exclude=self._current)
|
||||
return self._to_dto(self._current)
|
||||
|
||||
def mark_known(self) -> None:
|
||||
"""Remove the current card from the deck and save the remaining cards.
|
||||
|
||||
Does nothing and saves nothing when no card is shown.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
if self._current is None:
|
||||
return
|
||||
self._deck.remove(self._current)
|
||||
self._progress.save(self._deck)
|
||||
self._current = None
|
||||
|
||||
def _to_dto(self, card: Card | None) -> CardDto | None:
|
||||
"""Map a card to its DTO.
|
||||
|
||||
@param card The card, or None.
|
||||
@return The DTO, or None when there is no card.
|
||||
"""
|
||||
if card is None:
|
||||
return None
|
||||
pair = self._deck.language_pair
|
||||
return CardDto(card.front, card.back, pair.front_code, pair.back_code)
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
"""The cards still to learn.
|
||||
|
||||
@file deck.py
|
||||
@brief The Deck entity: holds cards, draws one and removes one.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.vocabulary import Vocabulary
|
||||
from flash_card.domain.errors.vocabulary_errors import EmptyDeckError
|
||||
from flash_card.domain.value_objects.language_pair import LanguagePair
|
||||
|
||||
|
||||
class Deck:
|
||||
"""The cards still to learn, of one language pair."""
|
||||
|
||||
def __init__(self, language_pair: LanguagePair, cards: Iterable[Card]) -> None:
|
||||
"""Create a deck.
|
||||
|
||||
@param language_pair The languages of the cards.
|
||||
@param cards The cards of the deck.
|
||||
"""
|
||||
## @brief The languages of the cards.
|
||||
self._language_pair = language_pair
|
||||
## @brief The cards still in the deck.
|
||||
self._cards = list(cards)
|
||||
|
||||
@classmethod
|
||||
def from_vocabulary(cls, vocabulary: Vocabulary) -> "Deck":
|
||||
"""Create a deck holding every card of a vocabulary.
|
||||
|
||||
@param vocabulary The delivered word list.
|
||||
@return A new deck.
|
||||
"""
|
||||
return cls(vocabulary.language_pair, vocabulary.cards)
|
||||
|
||||
@property
|
||||
def language_pair(self) -> LanguagePair:
|
||||
"""Return the languages of the cards.
|
||||
|
||||
@return The language pair.
|
||||
"""
|
||||
return self._language_pair
|
||||
|
||||
@property
|
||||
def cards(self) -> tuple[Card, ...]:
|
||||
"""Return a copy of the cards.
|
||||
|
||||
@return The cards in deck order.
|
||||
"""
|
||||
return tuple(self._cards)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
"""Tell whether the deck has no card.
|
||||
|
||||
@return True when no card is left.
|
||||
"""
|
||||
return not self._cards
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the number of cards.
|
||||
|
||||
@return The card count.
|
||||
"""
|
||||
return len(self._cards)
|
||||
|
||||
def draw(
|
||||
self,
|
||||
choose: Callable[[Sequence[Card]], Card],
|
||||
exclude: Card | None = None,
|
||||
) -> Card:
|
||||
"""Pick a card without removing it.
|
||||
|
||||
The pick avoids the excluded card when another card exists.
|
||||
|
||||
@param choose Picks one card from a non-empty sequence.
|
||||
@param exclude A card to avoid, usually the one shown now.
|
||||
@return The picked card.
|
||||
@throws EmptyDeckError When the deck has no card.
|
||||
"""
|
||||
if not self._cards:
|
||||
raise EmptyDeckError("cannot draw from an empty deck")
|
||||
others = [card for card in self._cards if card != exclude]
|
||||
return choose(others or self._cards)
|
||||
|
||||
def remove(self, card: Card) -> None:
|
||||
"""Remove the first card equal to the given one.
|
||||
|
||||
@param card The card to remove.
|
||||
@return Nothing.
|
||||
@throws ValueError When the deck holds no such card.
|
||||
"""
|
||||
self._cards.remove(card)
|
||||
"""The cards still to learn.
|
||||
|
||||
@file deck.py
|
||||
@brief The Deck entity: holds cards, draws one and removes one.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.vocabulary import Vocabulary
|
||||
from flash_card.domain.errors.vocabulary_errors import EmptyDeckError
|
||||
from flash_card.domain.value_objects.language_pair import LanguagePair
|
||||
|
||||
|
||||
class Deck:
|
||||
"""The cards still to learn, of one language pair."""
|
||||
|
||||
def __init__(self, language_pair: LanguagePair, cards: Iterable[Card]) -> None:
|
||||
"""Create a deck.
|
||||
|
||||
@param language_pair The languages of the cards.
|
||||
@param cards The cards of the deck.
|
||||
"""
|
||||
## @brief The languages of the cards.
|
||||
self._language_pair = language_pair
|
||||
## @brief The cards still in the deck.
|
||||
self._cards = list(cards)
|
||||
|
||||
@classmethod
|
||||
def from_vocabulary(cls, vocabulary: Vocabulary) -> "Deck":
|
||||
"""Create a deck holding every card of a vocabulary.
|
||||
|
||||
@param vocabulary The delivered word list.
|
||||
@return A new deck.
|
||||
"""
|
||||
return cls(vocabulary.language_pair, vocabulary.cards)
|
||||
|
||||
@property
|
||||
def language_pair(self) -> LanguagePair:
|
||||
"""Return the languages of the cards.
|
||||
|
||||
@return The language pair.
|
||||
"""
|
||||
return self._language_pair
|
||||
|
||||
@property
|
||||
def cards(self) -> tuple[Card, ...]:
|
||||
"""Return a copy of the cards.
|
||||
|
||||
@return The cards in deck order.
|
||||
"""
|
||||
return tuple(self._cards)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
"""Tell whether the deck has no card.
|
||||
|
||||
@return True when no card is left.
|
||||
"""
|
||||
return not self._cards
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the number of cards.
|
||||
|
||||
@return The card count.
|
||||
"""
|
||||
return len(self._cards)
|
||||
|
||||
def draw(
|
||||
self,
|
||||
choose: Callable[[Sequence[Card]], Card],
|
||||
exclude: Card | None = None,
|
||||
) -> Card:
|
||||
"""Pick a card without removing it.
|
||||
|
||||
The pick avoids the excluded card when another card exists.
|
||||
|
||||
@param choose Picks one card from a non-empty sequence.
|
||||
@param exclude A card to avoid, usually the one shown now.
|
||||
@return The picked card.
|
||||
@throws EmptyDeckError When the deck has no card.
|
||||
"""
|
||||
if not self._cards:
|
||||
raise EmptyDeckError("cannot draw from an empty deck")
|
||||
others = [card for card in self._cards if card != exclude]
|
||||
return choose(others or self._cards)
|
||||
|
||||
def remove(self, card: Card) -> None:
|
||||
"""Remove the first card equal to the given one.
|
||||
|
||||
@param card The card to remove.
|
||||
@return Nothing.
|
||||
@throws ValueError When the deck holds no such card.
|
||||
"""
|
||||
self._cards.remove(card)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Port to the words still to learn.
|
||||
|
||||
@file progress_repository.py
|
||||
@brief The ProgressRepository interface.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
|
||||
|
||||
class ProgressRepository(Protocol):
|
||||
"""Store of the words still to learn."""
|
||||
|
||||
def load(self) -> Deck | None:
|
||||
"""Load the saved words to learn.
|
||||
|
||||
@return The deck, or None when no progress was saved.
|
||||
@throws VocabularyError When the saved progress is unusable.
|
||||
"""
|
||||
...
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
"""Save the words still to learn.
|
||||
|
||||
@param deck The cards still to learn.
|
||||
@return Nothing.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,44 @@
|
||||
"""CSV implementation of the progress port.
|
||||
|
||||
@file csv_progress_repository.py
|
||||
@brief The CsvProgressRepository adapter.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.infrastructure.persistence.csv_word_list import (
|
||||
read_word_list,
|
||||
write_word_list,
|
||||
)
|
||||
|
||||
|
||||
class CsvProgressRepository:
|
||||
"""Keeps the words still to learn in a CSV file."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
"""Create the repository.
|
||||
|
||||
@param path The words to learn file.
|
||||
"""
|
||||
## @brief The words to learn file.
|
||||
self._path = path
|
||||
|
||||
def load(self) -> Deck | None:
|
||||
"""Load the saved words to learn.
|
||||
|
||||
@return The deck, possibly empty, or None when the file does not exist.
|
||||
@throws VocabularyFormatError When the file is malformed; it is left as is.
|
||||
"""
|
||||
if not self._path.exists():
|
||||
return None
|
||||
pair, cards = read_word_list(self._path)
|
||||
return Deck(pair, cards)
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
"""Save the words still to learn.
|
||||
|
||||
@param deck The cards still to learn.
|
||||
@return Nothing.
|
||||
"""
|
||||
write_word_list(self._path, deck.language_pair, deck.cards)
|
||||
@@ -1,35 +1,35 @@
|
||||
"""CSV implementation of the vocabulary port.
|
||||
|
||||
@file csv_vocabulary_repository.py
|
||||
@brief The CsvVocabularyRepository adapter.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flash_card.domain.entities.vocabulary import Vocabulary
|
||||
from flash_card.domain.errors.vocabulary_errors import VocabularyFormatError
|
||||
from flash_card.infrastructure.persistence.csv_word_list import read_word_list
|
||||
|
||||
|
||||
class CsvVocabularyRepository:
|
||||
"""Loads the delivered word list from a CSV file."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
"""Create the repository.
|
||||
|
||||
@param path The word list file.
|
||||
"""
|
||||
## @brief The word list file.
|
||||
self._path = path
|
||||
|
||||
def load(self) -> Vocabulary:
|
||||
"""Load the word list.
|
||||
|
||||
@return The vocabulary.
|
||||
@throws VocabularyNotFoundError When the file does not exist.
|
||||
@throws VocabularyFormatError When the file is malformed or has no card.
|
||||
"""
|
||||
pair, cards = read_word_list(self._path)
|
||||
if not cards:
|
||||
raise VocabularyFormatError(f"{self._path}: word list has no card")
|
||||
return Vocabulary(pair, cards)
|
||||
"""CSV implementation of the vocabulary port.
|
||||
|
||||
@file csv_vocabulary_repository.py
|
||||
@brief The CsvVocabularyRepository adapter.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flash_card.domain.entities.vocabulary import Vocabulary
|
||||
from flash_card.domain.errors.vocabulary_errors import VocabularyFormatError
|
||||
from flash_card.infrastructure.persistence.csv_word_list import read_word_list
|
||||
|
||||
|
||||
class CsvVocabularyRepository:
|
||||
"""Loads the delivered word list from a CSV file."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
"""Create the repository.
|
||||
|
||||
@param path The word list file.
|
||||
"""
|
||||
## @brief The word list file.
|
||||
self._path = path
|
||||
|
||||
def load(self) -> Vocabulary:
|
||||
"""Load the word list.
|
||||
|
||||
@return The vocabulary.
|
||||
@throws VocabularyNotFoundError When the file does not exist.
|
||||
@throws VocabularyFormatError When the file is malformed or has no card.
|
||||
"""
|
||||
pair, cards = read_word_list(self._path)
|
||||
if not cards:
|
||||
raise VocabularyFormatError(f"{self._path}: word list has no card")
|
||||
return Vocabulary(pair, cards)
|
||||
|
||||
@@ -1,86 +1,116 @@
|
||||
"""Reading of the word list CSV file.
|
||||
|
||||
@file csv_word_list.py
|
||||
@brief Reads a semicolon separated word list with a language code header.
|
||||
"""
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from flash_card.constants import CSV_DELIMITER, CSV_ENCODING
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.errors.vocabulary_errors import (
|
||||
VocabularyFormatError,
|
||||
VocabularyNotFoundError,
|
||||
)
|
||||
from flash_card.domain.value_objects.language_pair import LanguagePair
|
||||
|
||||
## @brief Number of cells in the header and in every card row.
|
||||
_CELLS_PER_ROW = 2
|
||||
|
||||
|
||||
def _format_error(path: Path, line: int, reason: str) -> VocabularyFormatError:
|
||||
"""Build a format error naming the file and line.
|
||||
|
||||
@param path The file read.
|
||||
@param line The line number of the fault.
|
||||
@param reason What is wrong.
|
||||
@return The error to raise.
|
||||
"""
|
||||
return VocabularyFormatError(f"{path}, line {line}: {reason}")
|
||||
|
||||
|
||||
def _parse_header(path: Path, line: int, row: list[str]) -> LanguagePair:
|
||||
"""Parse the header row.
|
||||
|
||||
@param path The file read.
|
||||
@param line The line number of the row.
|
||||
@param row The cells of the row.
|
||||
@return The language pair.
|
||||
@throws VocabularyFormatError When the header is not two codes.
|
||||
"""
|
||||
cells = [cell.strip() for cell in row]
|
||||
if len(cells) != _CELLS_PER_ROW or not all(cells):
|
||||
raise _format_error(path, line, "header must hold two language codes")
|
||||
return LanguagePair(cells[0], cells[1])
|
||||
|
||||
|
||||
def _parse_card(path: Path, line: int, row: list[str]) -> Card:
|
||||
"""Parse a card row.
|
||||
|
||||
@param path The file read.
|
||||
@param line The line number of the row.
|
||||
@param row The cells of the row.
|
||||
@return The card.
|
||||
@throws VocabularyFormatError When the row is not two cells.
|
||||
"""
|
||||
if len(row) != _CELLS_PER_ROW:
|
||||
raise _format_error(path, line, "row must hold exactly two cells")
|
||||
return Card(row[0].strip(), row[1].strip())
|
||||
|
||||
|
||||
def read_word_list(path: Path) -> tuple[LanguagePair, tuple[Card, ...]]:
|
||||
"""Read a word list file.
|
||||
|
||||
@param path The CSV file: a header of two language codes, then one card a row.
|
||||
@return The language pair and the cards in file order.
|
||||
@throws VocabularyNotFoundError When the file does not exist.
|
||||
@throws VocabularyFormatError When the file is empty or a row is malformed.
|
||||
"""
|
||||
pair: LanguagePair | None = None
|
||||
cards: list[Card] = []
|
||||
try:
|
||||
with path.open(encoding=CSV_ENCODING, newline="") as file:
|
||||
reader = csv.reader(file, delimiter=CSV_DELIMITER)
|
||||
for row in reader:
|
||||
if not any(cell.strip() for cell in row):
|
||||
continue
|
||||
if pair is None:
|
||||
pair = _parse_header(path, reader.line_num, row)
|
||||
else:
|
||||
cards.append(_parse_card(path, reader.line_num, row))
|
||||
except FileNotFoundError as error:
|
||||
raise VocabularyNotFoundError(f"word list not found: {path}") from error
|
||||
if pair is None:
|
||||
raise _format_error(path, 1, "file is empty")
|
||||
return pair, tuple(cards)
|
||||
"""Reading and writing of the word list CSV file.
|
||||
|
||||
@file csv_word_list.py
|
||||
@brief Reads and writes a semicolon separated word list with a language code
|
||||
header; the format is defined once here.
|
||||
"""
|
||||
|
||||
import csv
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
from flash_card.constants import CSV_DELIMITER, CSV_ENCODING
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.errors.vocabulary_errors import (
|
||||
VocabularyFormatError,
|
||||
VocabularyNotFoundError,
|
||||
)
|
||||
from flash_card.domain.value_objects.language_pair import LanguagePair
|
||||
|
||||
## @brief Number of cells in the header and in every card row.
|
||||
_CELLS_PER_ROW = 2
|
||||
|
||||
|
||||
def _format_error(path: Path, line: int, reason: str) -> VocabularyFormatError:
|
||||
"""Build a format error naming the file and line.
|
||||
|
||||
@param path The file read.
|
||||
@param line The line number of the fault.
|
||||
@param reason What is wrong.
|
||||
@return The error to raise.
|
||||
"""
|
||||
return VocabularyFormatError(f"{path}, line {line}: {reason}")
|
||||
|
||||
|
||||
def _parse_header(path: Path, line: int, row: list[str]) -> LanguagePair:
|
||||
"""Parse the header row.
|
||||
|
||||
@param path The file read.
|
||||
@param line The line number of the row.
|
||||
@param row The cells of the row.
|
||||
@return The language pair.
|
||||
@throws VocabularyFormatError When the header is not two codes.
|
||||
"""
|
||||
cells = [cell.strip() for cell in row]
|
||||
if len(cells) != _CELLS_PER_ROW or not all(cells):
|
||||
raise _format_error(path, line, "header must hold two language codes")
|
||||
return LanguagePair(cells[0], cells[1])
|
||||
|
||||
|
||||
def _parse_card(path: Path, line: int, row: list[str]) -> Card:
|
||||
"""Parse a card row.
|
||||
|
||||
@param path The file read.
|
||||
@param line The line number of the row.
|
||||
@param row The cells of the row.
|
||||
@return The card.
|
||||
@throws VocabularyFormatError When the row is not two cells.
|
||||
"""
|
||||
if len(row) != _CELLS_PER_ROW:
|
||||
raise _format_error(path, line, "row must hold exactly two cells")
|
||||
return Card(row[0].strip(), row[1].strip())
|
||||
|
||||
|
||||
def read_word_list(path: Path) -> tuple[LanguagePair, tuple[Card, ...]]:
|
||||
"""Read a word list file.
|
||||
|
||||
@param path The CSV file: a header of two language codes, then one card a row.
|
||||
@return The language pair and the cards in file order.
|
||||
@throws VocabularyNotFoundError When the file does not exist.
|
||||
@throws VocabularyFormatError When the file is empty or a row is malformed.
|
||||
"""
|
||||
pair: LanguagePair | None = None
|
||||
cards: list[Card] = []
|
||||
try:
|
||||
with path.open(encoding=CSV_ENCODING, newline="") as file:
|
||||
reader = csv.reader(file, delimiter=CSV_DELIMITER)
|
||||
for row in reader:
|
||||
if not any(cell.strip() for cell in row):
|
||||
continue
|
||||
if pair is None:
|
||||
pair = _parse_header(path, reader.line_num, row)
|
||||
else:
|
||||
cards.append(_parse_card(path, reader.line_num, row))
|
||||
except FileNotFoundError as error:
|
||||
raise VocabularyNotFoundError(f"word list not found: {path}") from error
|
||||
if pair is None:
|
||||
raise _format_error(path, 1, "file is empty")
|
||||
return pair, tuple(cards)
|
||||
|
||||
|
||||
def write_word_list(
|
||||
path: Path, language_pair: LanguagePair, cards: Iterable[Card]
|
||||
) -> None:
|
||||
"""Write a word list file atomically.
|
||||
|
||||
The rows go to a temporary file in the same folder, which then replaces the
|
||||
target, so a crash never leaves a half written file.
|
||||
|
||||
@param path The CSV file to create or replace.
|
||||
@param language_pair The languages, written as the header row.
|
||||
@param cards The cards, written one row each in the given order.
|
||||
@return Nothing.
|
||||
@throws OSError When the file cannot be written.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(path.name + ".tmp")
|
||||
rows = [(language_pair.front_code, language_pair.back_code)]
|
||||
rows.extend((card.front, card.back) for card in cards)
|
||||
try:
|
||||
with temporary.open("w", encoding=CSV_ENCODING, newline="") as file:
|
||||
writer = csv.writer(file, delimiter=CSV_DELIMITER, lineterminator="\n")
|
||||
writer.writerows(rows)
|
||||
temporary.replace(path)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
@@ -1,77 +1,85 @@
|
||||
"""Entry point and composition root of the flash card program.
|
||||
|
||||
@file app.py
|
||||
@brief Creates the window, its view model and view, and runs the main loop.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import tkinter as tk
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||
from flash_card.constants import DEFAULT_DATA_DIR, VOCABULARY_FILE_NAME
|
||||
from flash_card.domain.errors.vocabulary_errors import VocabularyError
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
||||
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
FlashCardViewModel,
|
||||
)
|
||||
|
||||
## @brief Logger of the entry point.
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_application(
|
||||
data_dir: Path | None = None,
|
||||
) -> tuple[tk.Tk, FlashCardView]:
|
||||
"""Create the root window and the flash card view.
|
||||
|
||||
@param data_dir Folder of the data files, or None for the default folder.
|
||||
@return The root window and the view built on it.
|
||||
@throws VocabularyError When the word list cannot be loaded.
|
||||
"""
|
||||
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
|
||||
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
|
||||
session = open_study_session(repository, random.choice)
|
||||
view_model = FlashCardViewModel(session)
|
||||
root = tk.Tk()
|
||||
view = FlashCardView(root, view_model)
|
||||
return root, view
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Run the program and return its exit code.
|
||||
|
||||
@param argv Command line arguments, or None for those of the process.
|
||||
@return The exit code, 0 on success and 1 when the word list is unusable.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="Flash card program.")
|
||||
parser.add_argument(
|
||||
"--data-dir",
|
||||
type=Path,
|
||||
default=DEFAULT_DATA_DIR,
|
||||
help="folder of the data files",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
root, _view = create_application(args.data_dir)
|
||||
except VocabularyError as error:
|
||||
_logger.error("%s", error)
|
||||
return 1
|
||||
root.mainloop()
|
||||
return 0
|
||||
|
||||
|
||||
def run() -> NoReturn:
|
||||
"""Run the program and exit the interpreter with its exit code.
|
||||
|
||||
@return Never returns; the function always raises SystemExit.
|
||||
@throws SystemExit Always, with the exit code of main.
|
||||
"""
|
||||
raise SystemExit(main())
|
||||
"""Entry point and composition root of the flash card program.
|
||||
|
||||
@file app.py
|
||||
@brief Creates the window, its view model and view, and runs the main loop.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import tkinter as tk
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||
from flash_card.constants import (
|
||||
DEFAULT_DATA_DIR,
|
||||
PROGRESS_FILE_NAME,
|
||||
VOCABULARY_FILE_NAME,
|
||||
)
|
||||
from flash_card.domain.errors.vocabulary_errors import VocabularyError
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
||||
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
FlashCardViewModel,
|
||||
)
|
||||
|
||||
## @brief Logger of the entry point.
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_application(
|
||||
data_dir: Path | None = None,
|
||||
) -> tuple[tk.Tk, FlashCardView]:
|
||||
"""Create the root window and the flash card view.
|
||||
|
||||
@param data_dir Folder of the data files, or None for the default folder.
|
||||
@return The root window and the view built on it.
|
||||
@throws VocabularyError When the word list or saved progress is unusable.
|
||||
"""
|
||||
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
|
||||
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
|
||||
progress = CsvProgressRepository(folder / PROGRESS_FILE_NAME)
|
||||
session = open_study_session(repository, progress, random.choice)
|
||||
view_model = FlashCardViewModel(session)
|
||||
root = tk.Tk()
|
||||
view = FlashCardView(root, view_model)
|
||||
return root, view
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Run the program and return its exit code.
|
||||
|
||||
@param argv Command line arguments, or None for those of the process.
|
||||
@return The exit code, 0 on success and 1 when the word list is unusable.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="Flash card program.")
|
||||
parser.add_argument(
|
||||
"--data-dir",
|
||||
type=Path,
|
||||
default=DEFAULT_DATA_DIR,
|
||||
help="folder of the data files",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
root, _view = create_application(args.data_dir)
|
||||
except VocabularyError as error:
|
||||
_logger.error("%s", error)
|
||||
return 1
|
||||
root.mainloop()
|
||||
return 0
|
||||
|
||||
|
||||
def run() -> NoReturn:
|
||||
"""Run the program and exit the interpreter with its exit code.
|
||||
|
||||
@return Never returns; the function always raises SystemExit.
|
||||
@throws SystemExit Always, with the exit code of main.
|
||||
"""
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -32,6 +32,21 @@ from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
)
|
||||
|
||||
|
||||
def _load_image(root: tk.Tk, name: str) -> tk.PhotoImage:
|
||||
"""Load an asset image.
|
||||
|
||||
The bytes are read here and handed to Tk as data, so the image does not
|
||||
depend on Tcl opening the file: Tcl keeps the standard handles it saw at
|
||||
start, and a process that swaps them (pytest fd capture) makes the first
|
||||
Tcl file open fail with an empty error.
|
||||
|
||||
@param root The Tk root window that owns the image.
|
||||
@param name The file name in the assets folder.
|
||||
@return The image; the caller must keep a reference.
|
||||
"""
|
||||
return tk.PhotoImage(master=root, data=(ASSETS_DIR / name).read_bytes())
|
||||
|
||||
|
||||
class FlashCardView:
|
||||
"""The flash card window: a card and the wrong and right buttons."""
|
||||
|
||||
@@ -50,13 +65,13 @@ class FlashCardView:
|
||||
|
||||
# Kept as attributes: Tkinter drops an image nothing refers to.
|
||||
## @brief Card front image, kept so Tk does not drop it.
|
||||
self._card_front_image = tk.PhotoImage(file=ASSETS_DIR / CARD_FRONT_IMAGE_NAME)
|
||||
self._card_front_image = _load_image(root, CARD_FRONT_IMAGE_NAME)
|
||||
## @brief Card back image, kept so Tk does not drop it.
|
||||
self._card_back_image = tk.PhotoImage(file=ASSETS_DIR / CARD_BACK_IMAGE_NAME)
|
||||
self._card_back_image = _load_image(root, CARD_BACK_IMAGE_NAME)
|
||||
## @brief Wrong button image, kept so Tk does not drop it.
|
||||
self._wrong_image = tk.PhotoImage(file=ASSETS_DIR / WRONG_IMAGE_NAME)
|
||||
self._wrong_image = _load_image(root, WRONG_IMAGE_NAME)
|
||||
## @brief Right button image, kept so Tk does not drop it.
|
||||
self._right_image = tk.PhotoImage(file=ASSETS_DIR / RIGHT_IMAGE_NAME)
|
||||
self._right_image = _load_image(root, RIGHT_IMAGE_NAME)
|
||||
|
||||
## @brief Canvas that draws the card.
|
||||
self.canvas = tk.Canvas(
|
||||
@@ -103,7 +118,7 @@ class FlashCardView:
|
||||
image=self._right_image,
|
||||
highlightthickness=0,
|
||||
bd=0,
|
||||
command=self.next_card,
|
||||
command=self.is_known,
|
||||
)
|
||||
self.right_button.grid(row=1, column=1)
|
||||
|
||||
@@ -137,6 +152,16 @@ class FlashCardView:
|
||||
self._show_card()
|
||||
self._start_flip_timer()
|
||||
|
||||
def is_known(self) -> None:
|
||||
"""Mark the card as known, show the next card front, restart the timer.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
self._cancel_flip_timer()
|
||||
self._view_model.is_known()
|
||||
self._show_card()
|
||||
self._start_flip_timer()
|
||||
|
||||
def flip_card(self) -> None:
|
||||
"""Show the back of the current card.
|
||||
|
||||
|
||||
@@ -1,104 +1,119 @@
|
||||
"""View model of the flash card screen.
|
||||
|
||||
@file flash_card_view_model.py
|
||||
@brief Exposes the texts shown on the card; free of any Tkinter import.
|
||||
"""
|
||||
|
||||
import enum
|
||||
from collections.abc import Mapping
|
||||
|
||||
from flash_card.application.dto.card_dto import CardDto
|
||||
from flash_card.application.use_cases.study_session import StudySession
|
||||
from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES
|
||||
|
||||
|
||||
class Side(enum.Enum):
|
||||
"""The side of a card that is shown."""
|
||||
|
||||
## @brief The front: the word to learn.
|
||||
FRONT = enum.auto()
|
||||
## @brief The back: the translation.
|
||||
BACK = enum.auto()
|
||||
|
||||
|
||||
class FlashCardViewModel:
|
||||
"""Data the flash card view shows."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: StudySession,
|
||||
language_names: Mapping[str, str] = LANGUAGE_NAMES,
|
||||
) -> None:
|
||||
"""Create the view model and draw the first card.
|
||||
|
||||
@param session The study session that supplies the cards.
|
||||
@param language_names Display names by language code.
|
||||
"""
|
||||
## @brief The study session that supplies the cards.
|
||||
self._session = session
|
||||
## @brief Display names by language code.
|
||||
self._language_names = language_names
|
||||
## @brief The card shown now, or None when none is left.
|
||||
self._card: CardDto | None = session.next_card()
|
||||
## @brief The side of the card shown.
|
||||
self._side = Side.FRONT
|
||||
|
||||
@property
|
||||
def is_finished(self) -> bool:
|
||||
"""Tell whether no card is left to show.
|
||||
|
||||
@return True when there is no card.
|
||||
"""
|
||||
return self._card is None
|
||||
|
||||
@property
|
||||
def side(self) -> Side:
|
||||
"""Return the side of the card shown.
|
||||
|
||||
@return FRONT or BACK; FRONT when no card is left.
|
||||
"""
|
||||
return self._side
|
||||
|
||||
@property
|
||||
def language_name(self) -> str:
|
||||
"""Return the language name shown on the card.
|
||||
|
||||
@return The display name of the language of the side shown, upper case
|
||||
code when unknown, or an empty string when no card is left.
|
||||
"""
|
||||
if self._card is None:
|
||||
return ""
|
||||
if self._side is Side.BACK:
|
||||
code = self._card.back_language
|
||||
else:
|
||||
code = self._card.front_language
|
||||
return self._language_names.get(code, code.upper())
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Return the word shown on the card.
|
||||
|
||||
@return The text of the side shown, or the finished message when no
|
||||
card is left.
|
||||
"""
|
||||
if self._card is None:
|
||||
return FINISHED_MESSAGE
|
||||
if self._side is Side.BACK:
|
||||
return self._card.back_text
|
||||
return self._card.front_text
|
||||
|
||||
def next_card(self) -> None:
|
||||
"""Draw the next card and show its front.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
self._card = self._session.next_card()
|
||||
self._side = Side.FRONT
|
||||
|
||||
def flip_card(self) -> None:
|
||||
"""Show the back of the card; does nothing when finished or flipped.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
if self._card is not None:
|
||||
self._side = Side.BACK
|
||||
"""View model of the flash card screen.
|
||||
|
||||
@file flash_card_view_model.py
|
||||
@brief Exposes the texts shown on the card; free of any Tkinter import.
|
||||
"""
|
||||
|
||||
import enum
|
||||
from collections.abc import Mapping
|
||||
|
||||
from flash_card.application.dto.card_dto import CardDto
|
||||
from flash_card.application.use_cases.study_session import StudySession
|
||||
from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES
|
||||
|
||||
|
||||
class Side(enum.Enum):
|
||||
"""The side of a card that is shown."""
|
||||
|
||||
## @brief The front: the word to learn.
|
||||
FRONT = enum.auto()
|
||||
## @brief The back: the translation.
|
||||
BACK = enum.auto()
|
||||
|
||||
|
||||
class FlashCardViewModel:
|
||||
"""Data the flash card view shows."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: StudySession,
|
||||
language_names: Mapping[str, str] = LANGUAGE_NAMES,
|
||||
) -> None:
|
||||
"""Create the view model and draw the first card.
|
||||
|
||||
@param session The study session that supplies the cards.
|
||||
@param language_names Display names by language code.
|
||||
"""
|
||||
## @brief The study session that supplies the cards.
|
||||
self._session = session
|
||||
## @brief Display names by language code.
|
||||
self._language_names = language_names
|
||||
## @brief The card shown now, or None when none is left.
|
||||
self._card: CardDto | None = session.next_card()
|
||||
## @brief The side of the card shown.
|
||||
self._side = Side.FRONT
|
||||
|
||||
@property
|
||||
def is_finished(self) -> bool:
|
||||
"""Tell whether no card is left to show.
|
||||
|
||||
@return True when there is no card.
|
||||
"""
|
||||
return self._card is None
|
||||
|
||||
@property
|
||||
def side(self) -> Side:
|
||||
"""Return the side of the card shown.
|
||||
|
||||
@return FRONT or BACK; FRONT when no card is left.
|
||||
"""
|
||||
return self._side
|
||||
|
||||
@property
|
||||
def language_name(self) -> str:
|
||||
"""Return the language name shown on the card.
|
||||
|
||||
@return The display name of the language of the side shown, upper case
|
||||
code when unknown, or an empty string when no card is left.
|
||||
"""
|
||||
if self._card is None:
|
||||
return ""
|
||||
if self._side is Side.BACK:
|
||||
code = self._card.back_language
|
||||
else:
|
||||
code = self._card.front_language
|
||||
return self._language_names.get(code, code.upper())
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Return the word shown on the card.
|
||||
|
||||
@return The text of the side shown, or the finished message when no
|
||||
card is left.
|
||||
"""
|
||||
if self._card is None:
|
||||
return FINISHED_MESSAGE
|
||||
if self._side is Side.BACK:
|
||||
return self._card.back_text
|
||||
return self._card.front_text
|
||||
|
||||
def next_card(self) -> None:
|
||||
"""Draw the next card and show its front; does nothing when finished.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
if self._card is None:
|
||||
return
|
||||
self._card = self._session.next_card()
|
||||
self._side = Side.FRONT
|
||||
|
||||
def is_known(self) -> None:
|
||||
"""Mark the card as known, save the progress and show the next card.
|
||||
|
||||
Does nothing when finished.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
if self._card is None:
|
||||
return
|
||||
self._session.mark_known()
|
||||
self._card = self._session.next_card()
|
||||
self._side = Side.FRONT
|
||||
|
||||
def flip_card(self) -> None:
|
||||
"""Show the back of the card; does nothing when finished or flipped.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
if self._card is not None:
|
||||
self._side = Side.BACK
|
||||
|
||||
Reference in New Issue
Block a user