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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user