From e65bef9c22244fc9bcd22a37d335f5f76ba16ff5 Mon Sep 17 00:00:00 2001 From: Eddy Pedroni Date: Thu, 26 Sep 2024 10:02:15 +0200 Subject: Create separate packages for library and CLI --- src/card.py | 13 ----- src/flashcard_cli.py | 58 --------------------- src/parser.py | 108 --------------------------------------- src/parser_unittest.py | 72 -------------------------- src/scheduler.py | 61 ---------------------- src/scheduler_brutal.py | 78 ---------------------------- src/scheduler_brutal_unittest.py | 87 ------------------------------- src/session.py | 50 ------------------ src/session_integrationtest.py | 46 ----------------- src/state_json.py | 24 --------- src/state_json_unittest.py | 13 ----- 11 files changed, 610 deletions(-) delete mode 100644 src/card.py delete mode 100644 src/flashcard_cli.py delete mode 100644 src/parser.py delete mode 100644 src/parser_unittest.py delete mode 100644 src/scheduler.py delete mode 100644 src/scheduler_brutal.py delete mode 100644 src/scheduler_brutal_unittest.py delete mode 100644 src/session.py delete mode 100644 src/session_integrationtest.py delete mode 100644 src/state_json.py delete mode 100644 src/state_json_unittest.py (limited to 'src') diff --git a/src/card.py b/src/card.py deleted file mode 100644 index c2243a6..0000000 --- a/src/card.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -Defines a struct representing a single card. The struct takes the form: - -(front, back) -""" - -from collections import namedtuple -from hashlib import md5 - -Card = namedtuple('Card', ['front', 'back']) - -def getId(card: Card) -> str: - return md5((card.front + card.back).encode("utf-8")).hexdigest() diff --git a/src/flashcard_cli.py b/src/flashcard_cli.py deleted file mode 100644 index be05be8..0000000 --- a/src/flashcard_cli.py +++ /dev/null @@ -1,58 +0,0 @@ -import click -from random import shuffle - -from scheduler import SCHEDULERS -from session import Session -from card import Card - -@click.group() -def cli(): - pass - -def displayCard(card: Card, index: int, random_flip: bool) -> None: - click.echo(click.style(f"{index + 1} ===========================================================", fg="blue")) - - faces = [card.front, card.back] - - if random_flip: - shuffle(faces) - - click.echo(click.style(faces.pop(0), fg="yellow")) - input() - click.echo(faces.pop(0)) - - -@cli.command() -@click.argument("state_file", nargs=1, type=click.Path()) -@click.argument("card_files", nargs=-1, type=click.Path(exists=True)) -@click.option("--scheduler", "scheduler_name", default="brutal", type=click.Choice(SCHEDULERS, case_sensitive=False), help="Name of desired scheduler") -@click.option("--count", default=20, type=int, help="Number of cards to show during the session") -@click.option("--random_flip", is_flag=True, help="Prompt with card front or back randomly instead of always front") -def practice(state_file, card_files, scheduler_name, count, random_flip): - """ - Run a practice session with the specified scheduler, using the provided state and card files. - """ - session = Session(scheduler_name, card_files, state_file) - - for i, card in enumerate(session.practice(count)): - displayCard(card, i, random_flip) - -@cli.command() -@click.argument("state_file", nargs=1, type=click.Path()) -@click.argument("card_files", nargs=-1, type=click.Path(exists=True)) -@click.option("--scheduler", "scheduler_name", default="brutal", type=click.Choice(SCHEDULERS, case_sensitive=False), help="Name of desired scheduler") -@click.option("--count", default=20, type=int, help="Number of cards to show during the session") -@click.option("--random_flip", is_flag=True, help="Prompt with card front or back randomly instead of always front") -def test(state_file, card_files, scheduler_name, count, random_flip): - """ - Run a test session with the specified scheduler, using the provided state and card files. - """ - session = Session(scheduler_name, card_files, state_file) - - for i, (card, correct) in enumerate(session.test(count)): - displayCard(card, i, random_flip) - correct(click.confirm(click.style("Correct?", bold=True))) - -if __name__ == '__main__': - cli() - diff --git a/src/parser.py b/src/parser.py deleted file mode 100644 index 11bb0c6..0000000 --- a/src/parser.py +++ /dev/null @@ -1,108 +0,0 @@ -""" -Load .fcard files into dictionaries. - -The parser expects .fcard files in the following format: - -FRONT -This is the front of the first card. - -BACK -This is the back of the first card. - -FRONT -This is another card. - -Multiple lines on the front are allowed. - -BACK -Multiple lines on the back? - -Also allowed. - -FRONT -... - -The cards are represented in dictionary entries of the form: - -id: card.Card -""" - -from pathlib import Path -from enum import Enum -from typing import TextIO, Iterator -from card import Card, getId - -def _getCard(front_lines: list[str], back_lines: list[str]) -> tuple[str, Card]: - front_text = "".join(front_lines).strip() - back_text = "".join(back_lines).strip() - card = Card(front_text, back_text) - id = getId(card) - return id, card - -def _getCards(f: TextIO) -> Iterator[tuple[str, Card]]: - class State(Enum): - PARSE_FRONT = 1, - PARSE_BACK = 2 - - state = None - front_lines = [] - back_lines = [] - - for i, line in enumerate(f): - match line.strip(): - case "FRONT": - # Edge case: FRONT twice in a row - if state == State.PARSE_FRONT: - raise Exception(f"Unexpected 'FRONT': {f}:{i}") - - # Next card is starting, wrap up current one - if state == State.PARSE_BACK: - yield _getCard(front_lines, back_lines) - front_lines.clear() - back_lines.clear() - - state = State.PARSE_FRONT - - case "BACK": - # Edge case: BACK without FRONT before it - if state != State.PARSE_FRONT: - raise Exception(f"Unexpected 'BACK': {f}:{i}") - - state = State.PARSE_BACK - - case _: - match state: - case State.PARSE_FRONT: - front_lines += line - case State.PARSE_BACK: - back_lines += line - # Edge case: file does not start with FRONT, flush preamble - case _: - continue - - # Edge case: file did not end with contents of BACK - if state == State.PARSE_FRONT: - raise Exception(f"Unexpected end of file") - - # Edge case: file was empty - if state is None: - return - - yield _getCard(front_lines, back_lines) - -def parseFile(path: str) -> dict[str, Card]: - """ - Parse a .fcard file and return a dictionary of Card instances indexed by ID. - """ - with open(path, "r") as f: - return {id : card for id, card in _getCards(f)} - -def parseFiles(paths: list[str]) -> dict[str, Card]: - """ - Parse a list of .fcard files and return a dictionary of Card instances indexed by ID. - """ - cards = {} - for p in paths: - cards |= parseFile(p) - return cards - diff --git a/src/parser_unittest.py b/src/parser_unittest.py deleted file mode 100644 index 8d0d600..0000000 --- a/src/parser_unittest.py +++ /dev/null @@ -1,72 +0,0 @@ -import pytest -import parser -from pathlib import Path - -# Happy path -def test_validFile(tmp_path): - file_contents = """ - -FRONT - - -Foo - -Bar - -BACK - - -Fizz - -Buzz - - - -FRONT - -Another card - -BACK - -Another back - - - """ - expected = { - ("Foo\n\nBar", "Fizz\n\nBuzz"), - ("Another card", "Another back") - } - - path = tmp_path / "valid_file.fcard" - with open(path, "w") as f: - f.write(file_contents) - - cards = parser.parseFile(path) - - assert expected == set(cards.values()) - -# Edge cases -def test_emptyFile(tmp_path): - path = tmp_path / "empty.fcard" - with open(path, "w") as f: - f.write("") - - cards = parser.parseFile(path) - assert cards == {} - -def checkException(tmp_path, file_contents): - path = tmp_path / "invalid_file.fcard" - with open(path, "w") as f: - f.write(file_contents) - - with pytest.raises(Exception): - cards = parser.parseFile(path) - -def test_doesNotStartWithFront(tmp_path): - checkException(tmp_path, "BACK\noops") - -def test_frontTwiceInARow(tmp_path): - checkException(tmp_path, "FRONT\noops\nFRONT\nbad") - -def test_doesNotEndWithBack(tmp_path): - checkException(tmp_path, "FRONT\ntest\nBACK\ntest\nFRONT\noops") diff --git a/src/scheduler.py b/src/scheduler.py deleted file mode 100644 index e1c783b..0000000 --- a/src/scheduler.py +++ /dev/null @@ -1,61 +0,0 @@ -from typing import Protocol -from abc import abstractmethod -from card import Card - -class Scheduler(Protocol): - """ - Schedulers must implement this interface to be usable in a session. - """ - @abstractmethod - def __init__(self, cards: dict[str, Card], state: dict): - """ - Create a new instance of the scheduler from a dictionary of - Cards indexed by ID and a scheduler-specific state as a dict. - """ - raise NotImplementedError - - @abstractmethod - def practice(self, size: int) -> list[str]: - """ - Return a list of card IDs of the requested size, if possible. - This list is intended for practice. - """ - raise NotImplementedError - - @abstractmethod - def test(self, size: int) -> list[str]: - """ - Return a list of card IDs of the requested size, if possible. - This list is intended to test the player's knowledge. - """ - raise NotImplementedError - - @abstractmethod - def update(self, results: dict[str, int]) -> None: - """ - Takes a dictionary of card IDs and integers, where the integer - is 0 if the player failed to guess the other side of the card, - of 1 if the player succeeded. - """ - raise NotImplementedError - - @abstractmethod - def getState(self) -> dict: - """ - Return the scheduler's state for storage. - """ - raise NotImplementedError - -SCHEDULERS = ["brutal"] - -def getSchedulerClass(name: str) -> Scheduler: - """ - Returns the class object for the requested scheduler, if one exists. - """ - match name: - case "brutal": - from scheduler_brutal import SchedulerBrutal - return SchedulerBrutal - case _: - raise Exception(f"Unknown scheduler: {name}") - diff --git a/src/scheduler_brutal.py b/src/scheduler_brutal.py deleted file mode 100644 index f0bfe99..0000000 --- a/src/scheduler_brutal.py +++ /dev/null @@ -1,78 +0,0 @@ -from scheduler import Scheduler -from card import Card -from random import shuffle - -HISTORY_DEPTH = 8 - -class SchedulerBrutal(Scheduler): - """ - The brutal scheduler tracks how well the player has consolidated each card - and also how often the card has been shown. - - Using this information, it prioritizes cards that have been shown less - frequently and recently, which means the player will often see totally new - cards in test sessions. - """ - def __init__(self, cards: dict[str, Card], state: dict): - self._cards = cards - self._state = {} - - # Synchronise state with current card collection - for id, card in self._cards.items(): - history = state.get(id, [None] * HISTORY_DEPTH) - - # Adjust history if depth has changed - if len(history) > HISTORY_DEPTH: - history = history[-HISTORY_DEPTH:] - elif len(history) < HISTORY_DEPTH: - history = ([None] * (HISTORY_DEPTH - len(history))) + history - - self._state[id] = history - - def practice(self, size: int) -> list[str]: - return self._schedule(size) - - def test(self, size: int) -> list[str]: - return self._schedule(size) - - def update(self, results: dict[str, int]) -> None: - # Add card result to sliding window, or None if card was not shown - self._state = {id: history[1:] + [results.get(id, None)] - for id, history in self._state.items()} - - def getState(self) -> dict: - return self._state - - @staticmethod - def _consolidationIndex(history: list, weights: range) -> float: - """ - Consolidation index is a measure of how well the player has guessed the card recently - """ - relevant_history = [(h, w) for h, w in zip(history, weights) if h is not None] - weighted_history = sum([h * w for h, w in relevant_history]) - total_weights = sum([w for h, w in relevant_history]) - return weighted_history / total_weights if total_weights > 0 else 0.0 - - @staticmethod - def _exposureIndex(history: list) -> float: - """ - Exposure index is a measure of how much and how recently a card has been shown - """ - return sum([i + 1 for i, h in enumerate(history) if h is not None]) - - def _schedule(self, size: int) -> list[str]: - weights = range(10, 10 + HISTORY_DEPTH) - cards = [id for id, card in self._cards.items()] - - # First sort by consolidation index - cards.sort(key=lambda id: SchedulerBrutal._consolidationIndex(self._state[id], weights)) - - # Next sort by exposure index - cards.sort(key=lambda id: SchedulerBrutal._exposureIndex(self._state[id])) - - # Return least exposed and least consolidated cards, shuffled - cards = cards[0:size] - - shuffle(cards) - - return cards diff --git a/src/scheduler_brutal_unittest.py b/src/scheduler_brutal_unittest.py deleted file mode 100644 index 8dc72fe..0000000 --- a/src/scheduler_brutal_unittest.py +++ /dev/null @@ -1,87 +0,0 @@ -import pytest -import scheduler_brutal -from scheduler_brutal import SchedulerBrutal as UUT -from card import Card - -# Force HISTORY_DEPTH to simplify testing -scheduler_brutal.HISTORY_DEPTH = 3 - -#-------------------------------------------------------------------------- -# Scheduling behaviour -#-------------------------------------------------------------------------- -def test_scheduling(): - cards = {str(id): Card("", "") for id in range(0, 10)} - state = { - "0": [1, 1, 1], - "1": [0, 0, 0], - "2": [0, 0, 1], - "3": [1, 0, 0], - - "4": [None, None, 1 ], - "5": [None, 1, None], - "6": [1, None, None], - "7": [None, None, 0 ], - "8": [0, 0, None], - "9": [None, None, None], - } - - expected_priority = ["9", "6", "5", "7", "8", "4", "1", "3", "2", "0"] - - uut = UUT(cards, state) - - for i in range(0, len(expected_priority)): - assert set(uut.practice(i + 1)) == set(expected_priority[0:i + 1]) - -#-------------------------------------------------------------------------- -# State update -#-------------------------------------------------------------------------- -def test_stateUpdate(): - cards = {"0": Card("f", "b"), "1": Card("a", "b"), "2": Card("c", "d")} - state = {"0": [1, 0, 1], "1": [1, 0, 0], "2": [0, 0, 1]} - - uut = UUT(cards, state) - - # Unknown IDs in the result are silently ignored - result = {"0": 1, "1": 0, "3": 0} - expected_state = {"0": [0, 1, 1], "1": [0, 0, 0], "2": [0, 1, None]} - - uut.update(result) - - assert uut.getState() == expected_state - -#-------------------------------------------------------------------------- -# State corrections -#-------------------------------------------------------------------------- -def test_stateWhenCardsChanged(): - cards = {"0": Card("f", "b"), "1": Card("a", "b")} - - initial_state = {"0": [1, 0, 1], "2": [0, 0, 0]} - expected_state = {"0": [1, 0, 1], "1": [None, None, None]} - - uut = UUT(cards, initial_state) - - assert uut.getState() == expected_state - -def test_stateWhenHistoryDepthIncreased(): - scheduler_brutal.HISTORY_DEPTH = 5 - - cards = {"0": Card("f", "b"), "1": Card("a", "b"), "2": Card("new", "new")} - - initial_state = {"0": [1, 0, 1], "1": [0, 0, 0]} - expected_state = {"0": [None, None, 1, 0, 1], "1": [None, None, 0, 0, 0], "2": [None] * 5} - - uut = UUT(cards, initial_state) - - assert uut.getState() == expected_state - -def test_stateWhenHistoryDepthDecreased(): - scheduler_brutal.HISTORY_DEPTH = 1 - - cards = {"0": Card("f", "b"), "1": Card("a", "b"), "2": Card("new", "new")} - - initial_state = {"0": [1, 0, 0], "1": [0, 0, 1]} - expected_state = {"0": [0], "1": [1], "2": [None]} - - uut = UUT(cards, initial_state) - - assert uut.getState() == expected_state diff --git a/src/session.py b/src/session.py deleted file mode 100644 index f30160f..0000000 --- a/src/session.py +++ /dev/null @@ -1,50 +0,0 @@ -from typing import Iterator, Callable - -from card import Card -from scheduler import getSchedulerClass -from parser import parseFiles -from state_json import load, save - -class Session: - """ - Represents a play session. During a session, multiple practice and test runs - can be made with the same scheduler. - """ - def __init__(self, scheduler_name: str, card_files: list[str], state_file: str): - self._cards = parseFiles(card_files) - self._state_file = state_file - self._scheduler = getSchedulerClass(scheduler_name)(self._cards, load(state_file)) - - def practice(self, size: int) -> Iterator[Card]: - """ - Yields cards for a practice run of the requested size. - - Practice runs do not affect the scheduler state. - """ - ids = self._scheduler.practice(size) - for id in ids: - yield self._cards[id] - - def test(self, size: int) -> Iterator[tuple[Card, Callable]]: - """ - Yields cards for a test run of the requested size. - - A function is yielded with each card that takes single boolean argument. - The UI is expected to call the function for each card to indicate whether - the user correctly guessed the card (True) or not (False). - - Multiple subsequent calls to the same function overwrite past results. - - When the test run is done, the scheduler state is updated with the - collected results - """ - ids = self._scheduler.practice(size) - results = {} - - for id in ids: - def result(correct: bool) -> None: - results[id] = int(correct) - yield self._cards[id], result - - self._scheduler.update(results) - save(self._state_file, self._scheduler.getState()) diff --git a/src/session_integrationtest.py b/src/session_integrationtest.py deleted file mode 100644 index 2c5d608..0000000 --- a/src/session_integrationtest.py +++ /dev/null @@ -1,46 +0,0 @@ -import pytest -import json -from session import Session - -@pytest.fixture -def cardFiles(tmp_path): - card_files = [ - tmp_path / "test1.fcard", - tmp_path / "test2.fcard", - ] - - for i, c in enumerate(card_files): - with open(c, "w") as f: - for j in range(0, 3): - f.write(f"FRONT\nFile {i}, card {j} front\nBACK\nback\n") - - return card_files - -@pytest.fixture -def stateFile(tmp_path): - return tmp_path / "state.json" - -def test_practiceSession(cardFiles, stateFile): - session = Session("brutal", cardFiles, stateFile) - - c = 0 - for card in session.practice(5): - c += 1 - - assert c == 5 - -def test_testSession(cardFiles, stateFile): - session = Session("brutal", cardFiles, stateFile) - - c = 0 - for i, (card, correct) in enumerate(session.test(5)): - c += 1 - correct(i % 2 == 0) - - assert c == 5 - - with open(stateFile, "r") as f: - state = json.load(f) - latest_scores = [history[-1] for id, history in state.items()] - assert latest_scores.count(0) == 2 - assert latest_scores.count(1) == 3 diff --git a/src/state_json.py b/src/state_json.py deleted file mode 100644 index a0b487e..0000000 --- a/src/state_json.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -Helper functions to store scheduler state as json -""" - -import json -from pathlib import Path - -def save(file: str, state: dict) -> None: - """ - Dump the specified state dictionary in JSON format - """ - with open(file, "w") as f: - json.dump(state, f) - -def load(file: str) -> dict: - """ - Load the state from the specified file and return - an empty dictionary silently if the file doesn't exist. - """ - try: - with open(file, "r") as f: - return json.load(f) - except: - return {} diff --git a/src/state_json_unittest.py b/src/state_json_unittest.py deleted file mode 100644 index e784f58..0000000 --- a/src/state_json_unittest.py +++ /dev/null @@ -1,13 +0,0 @@ -import pytest -import state_json - -def test_saveAndLoad(tmp_path): - file = tmp_path / "test.json" - state = {"key": [10, 20, None], "another_key": "value"} - - state_json.save(file, state) - - assert state_json.load(file) == state - -def test_missingFile(tmp_path): - assert state_json.load(tmp_path / "missing.json") == {} -- cgit v1.2.3