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 --- .gitignore | 1 + bootstrap-venv.sh | 1 + cli-project/flashcard_cli.py | 55 +++++++++++ cli-project/pyproject.toml | 19 ++++ flashcards | 92 ------------------ flashcards-project/pyproject.toml | 14 +++ flashcards-project/src/flashcards/__init__.py | 2 + flashcards-project/src/flashcards/card.py | 12 +++ flashcards-project/src/flashcards/parser.py | 108 +++++++++++++++++++++ flashcards-project/src/flashcards/scheduler.py | 62 ++++++++++++ .../src/flashcards/scheduler_brutal.py | 79 +++++++++++++++ flashcards-project/src/flashcards/session.py | 51 ++++++++++ flashcards-project/src/flashcards/state_json.py | 23 +++++ requirements.txt | 2 + 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 --- tests/parser_unittest.py | 73 ++++++++++++++ tests/scheduler_brutal_unittest.py | 88 +++++++++++++++++ tests/session_integrationtest.py | 47 +++++++++ tests/state_json_unittest.py | 14 +++ 29 files changed, 651 insertions(+), 702 deletions(-) create mode 100644 cli-project/flashcard_cli.py create mode 100644 cli-project/pyproject.toml delete mode 100755 flashcards create mode 100644 flashcards-project/pyproject.toml create mode 100644 flashcards-project/src/flashcards/__init__.py create mode 100644 flashcards-project/src/flashcards/card.py create mode 100644 flashcards-project/src/flashcards/parser.py create mode 100644 flashcards-project/src/flashcards/scheduler.py create mode 100644 flashcards-project/src/flashcards/scheduler_brutal.py create mode 100644 flashcards-project/src/flashcards/session.py create mode 100644 flashcards-project/src/flashcards/state_json.py 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 create mode 100644 tests/parser_unittest.py create mode 100644 tests/scheduler_brutal_unittest.py create mode 100644 tests/session_integrationtest.py create mode 100644 tests/state_json_unittest.py diff --git a/.gitignore b/.gitignore index 92afa22..fb4ac55 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__/ venv/ +**/*.egg-info diff --git a/bootstrap-venv.sh b/bootstrap-venv.sh index b105996..f3c5580 100755 --- a/bootstrap-venv.sh +++ b/bootstrap-venv.sh @@ -1,4 +1,5 @@ #!/usr/bin/zsh +rm -r venv python -m venv venv ./venv/bin/pip install -r requirements.txt diff --git a/cli-project/flashcard_cli.py b/cli-project/flashcard_cli.py new file mode 100644 index 0000000..27a3a77 --- /dev/null +++ b/cli-project/flashcard_cli.py @@ -0,0 +1,55 @@ +import click +from random import shuffle + +from flashcards import Session, SCHEDULERS + +@click.group() +def cli(): + pass + +def displayCard(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/cli-project/pyproject.toml b/cli-project/pyproject.toml new file mode 100644 index 0000000..333c9cd --- /dev/null +++ b/cli-project/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "flashcards_cli" +authors = [ + {name = "Eddy Pedroni", email = "epedroni@pm.me"}, +] +description = "A CLI frontend for the flashcards library" +requires-python = ">=3.12" +dependencies = [ + "click", + "flashcards" +] +dynamic = ["version"] + +[project.scripts] +flashcard_cli = "flashcard_cli:cli" diff --git a/flashcards b/flashcards deleted file mode 100755 index e0ba548..0000000 --- a/flashcards +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/python3 - -import re -import sys -from pathlib import Path -from random import shuffle - -class Color: - PURPLE = '\033[95m' - CYAN = '\033[96m' - DARKCYAN = '\033[36m' - BLUE = '\033[94m' - GREEN = '\033[92m' - YELLOW = '\033[93m' - RED = '\033[91m' - BOLD = '\033[1m' - UNDERLINE = '\033[4m' - END = '\033[0m' - -#print color.BOLD + 'Hello World !' + color.END - -cardRegex = "CARD: " -prefixLength = len(cardRegex) - -# Returns a list of Path objects, containing the path to each valid file provided -def getFileList(): - fileList = [] - if len(sys.argv) > 1: - for f in sys.argv[1:]: - path = Path(f) - if path.exists() and path.is_file(): - fileList.append(f) - return fileList - else: - print("Missing arguments") - sys.exit() - -# Returns cards in the form [(front, back)] -def createCardList(files): - cards = [] - for f in files: - cards = cards + extractCards(f) - return cards - -# Extracts cards from a single file into a list of the form [(front, back)] -def extractCards(f): - front = "" - back = "" - cards = [] - with open(f) as cardFile: - for l in cardFile: - match = re.match(cardRegex, l) - if match: - if front != "": - cards.append([front.strip(), back.strip()]) - back = "" - front = match.string[prefixLength:] - else: - back += l - # do the last front-back pair before returning - cards.append([front.strip(), back.strip()]) - return cards - -# Waits for user input and reacts accordingly -def wait(): - cmd = input().strip() - if cmd.startswith("q") or cmd.startswith("quit") or cmd.startswith("exit"): - sys.exit(0) - -# Loops serving cards to the user until the program is exited -def serveCards(cards): - while True: - for i, card in enumerate(cards): - print("----------------------------------------------------------------------------(" + str(i + 1) + "/" + str(len(cards)) + ")") - print(Color.BLUE + Color.BOLD + card[0] + Color.END) - wait() - print(card[1]) - wait() - -def debugCards(cardList): - for c in cardList: - print("Front:", c[0]) - print("Back:", c[1]) - -def main(): - files = getFileList() - cards = createCardList(files) - shuffle(cards) - serveCards(cards) - -if __name__ == "__main__": - main() diff --git a/flashcards-project/pyproject.toml b/flashcards-project/pyproject.toml new file mode 100644 index 0000000..06a6931 --- /dev/null +++ b/flashcards-project/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "flashcards" +authors = [ + {name = "Eddy Pedroni", email = "epedroni@pm.me"}, +] +description = "A library for memorising information with flashcards" +requires-python = ">=3.12" +dependencies = [ +] +dynamic = ["version"] diff --git a/flashcards-project/src/flashcards/__init__.py b/flashcards-project/src/flashcards/__init__.py new file mode 100644 index 0000000..38c9936 --- /dev/null +++ b/flashcards-project/src/flashcards/__init__.py @@ -0,0 +1,2 @@ +from .session import Session +from .scheduler import SCHEDULERS diff --git a/flashcards-project/src/flashcards/card.py b/flashcards-project/src/flashcards/card.py new file mode 100644 index 0000000..3278343 --- /dev/null +++ b/flashcards-project/src/flashcards/card.py @@ -0,0 +1,12 @@ +""" +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/flashcards-project/src/flashcards/parser.py b/flashcards-project/src/flashcards/parser.py new file mode 100644 index 0000000..38abdcc --- /dev/null +++ b/flashcards-project/src/flashcards/parser.py @@ -0,0 +1,108 @@ +""" +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/flashcards-project/src/flashcards/scheduler.py b/flashcards-project/src/flashcards/scheduler.py new file mode 100644 index 0000000..a9d9470 --- /dev/null +++ b/flashcards-project/src/flashcards/scheduler.py @@ -0,0 +1,62 @@ +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/flashcards-project/src/flashcards/scheduler_brutal.py b/flashcards-project/src/flashcards/scheduler_brutal.py new file mode 100644 index 0000000..ebbc0ff --- /dev/null +++ b/flashcards-project/src/flashcards/scheduler_brutal.py @@ -0,0 +1,79 @@ +from random import shuffle + +from .scheduler import Scheduler +from .card import Card + +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/flashcards-project/src/flashcards/session.py b/flashcards-project/src/flashcards/session.py new file mode 100644 index 0000000..da444dd --- /dev/null +++ b/flashcards-project/src/flashcards/session.py @@ -0,0 +1,51 @@ +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/flashcards-project/src/flashcards/state_json.py b/flashcards-project/src/flashcards/state_json.py new file mode 100644 index 0000000..673d904 --- /dev/null +++ b/flashcards-project/src/flashcards/state_json.py @@ -0,0 +1,23 @@ +""" +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/requirements.txt b/requirements.txt index ad90bc2..17a9a3d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ click pytest +-e flashcards-project +-e cli-project 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") == {} diff --git a/tests/parser_unittest.py b/tests/parser_unittest.py new file mode 100644 index 0000000..8076369 --- /dev/null +++ b/tests/parser_unittest.py @@ -0,0 +1,73 @@ +import pytest +from pathlib import Path + +from flashcards import parser + +# 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/tests/scheduler_brutal_unittest.py b/tests/scheduler_brutal_unittest.py new file mode 100644 index 0000000..a87e5e9 --- /dev/null +++ b/tests/scheduler_brutal_unittest.py @@ -0,0 +1,88 @@ +import pytest + +from flashcards import scheduler_brutal +from flashcards.scheduler_brutal import SchedulerBrutal as UUT +from flashcards.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/tests/session_integrationtest.py b/tests/session_integrationtest.py new file mode 100644 index 0000000..ddabff9 --- /dev/null +++ b/tests/session_integrationtest.py @@ -0,0 +1,47 @@ +import pytest +import json + +from flashcards.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/tests/state_json_unittest.py b/tests/state_json_unittest.py new file mode 100644 index 0000000..03ea555 --- /dev/null +++ b/tests/state_json_unittest.py @@ -0,0 +1,14 @@ +import pytest + +from flashcards 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