diff options
| author | Florian Fischer <florian.fischer@muhq.space> | 2023-07-17 19:06:11 +0200 |
|---|---|---|
| committer | Florian Fischer <florian.fischer@muhq.space> | 2023-07-17 19:06:11 +0200 |
| commit | 927c0df42db96a7e661e30aae90ec9f52d83dc37 (patch) | |
| tree | 99532c47fd789ad0cb1c4c4492841c555c7bae25 /quiz.py | |
| download | quiz-927c0df42db96a7e661e30aae90ec9f52d83dc37.tar.gz quiz-927c0df42db96a7e661e30aae90ec9f52d83dc37.zip | |
initial commit
Diffstat (limited to 'quiz.py')
| -rwxr-xr-x | quiz.py | 230 |
1 files changed, 230 insertions, 0 deletions
@@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +# Copyright 2023 Florian Fischer +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# this program. If not, see <https://www.gnu.org/licenses/>. +"""simple interactive quiz with variable question pool""" + +import argparse +import csv +from pathlib import Path +import random +import tomllib +import subprocess +import sys + +import PySimpleGUI as sg +import psutil + +WRONG_SOUND = Path(__file__).parent / 'Wrong.mp3' +CORRECT_SOUND = Path(__file__).parent / 'Correct.mp3' + +SHOW_QUESTION_CMD = 'xdg-open' +PLAY_SOUND_CMD = 'mpv' + + +def err(msg: str, status=1): + """Print the error message to stderr and exit with status""" + print(msg) + sys.exit(status) + + +def play_sound(sound: Path): + """Play a sound file""" + subprocess.run(f'{PLAY_SOUND_CMD} {sound}'.split(), + check=True, + stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL) + + +def terminate_subprocesses(): + """Terminate all started subprocesses""" + procs = psutil.Process().children(recursive=True) + for proc in procs: + proc.terminate() + _, alive = psutil.wait_procs(procs) + for proc in alive: + proc.kill() + + +def possible_questions(question_dirs: list[Path]) -> list[Path]: + """return all file in the question directories""" + questions = [] + ignored_file_names = [Path(__file__).name, 'solutions'] + for question_dir in question_dirs: + if not question_dir.is_dir(): + err(f'question directory {question_dir} is not a directory') + + questions.extend([ + p for p in question_dir.iterdir() if p.is_file() + if p.name not in ignored_file_names + ]) + + return questions + + +def read_solutions_from_toml(solution_path: Path, + selector='') -> dict[str, str]: + """return solutions from a toml solutions file + + Nested tables can be selected using the selector. + """ + + with open(solution_path, 'rb') as solution_file: + solution_data = tomllib.load(solution_file) + + if selector: + for key in selector.split('.'): + solution_data = solution_data[key] + + return solution_data + + +def read_solutions_from_csv(path: Path) -> dict[str, str]: + """return solutions from a csv solutions file""" + with open(path, 'r', encoding='utf-8') as solution_file: + csvreader = csv.reader(solution_file, delimiter=',') + solution_data = list(csvreader) + + # Skip obtional header + if solution_data[0] == ['question', 'solution']: + solution_data = solution_data[1:] + + return dict(solution_data) + + +def load_solutions(question_dirs: list[Path], + solution_files: list[Path], + selector='') -> dict[str, str]: + """return solutions for the loaded questions + + solutions must be either in a solutions file per question directory or in + a single solutions file. + Solution files must be toml or csv files. + Nested tables can be selected using the selector. + """ + solutions = {} + + if len(question_dirs) == len(solution_files): + q_s_mapping = list(zip(question_dirs, solution_files)) + elif len(solution_files) == 1: + q_s_mapping = [(qd, solution_files[0]) for qd in question_dirs] + else: + err('question and solution file mismatch') + + for question_dir, solution_path in q_s_mapping: + if not solution_path.is_file(): + err(f'solution file {solution_path} does not exist') + + if solution_path.suffix == '.toml': + solutions_from_file = read_solutions_from_toml(solution_path, + selector=selector) + elif solution_path.suffix == '.csv': + solutions_from_file = read_solutions_from_csv(solution_path) + else: + err(f'Unsupported solution file format: {solution_path.suffix}') + + flattened_solutions = { + f'{question_dir / k}': str(v) + for k, v in solutions_from_file.items() + } + solutions.update(flattened_solutions) + + return solutions + + +def show_question(question: Path) -> subprocess.Popen: + """show the question in an external programm and return the process""" + cmd = f'{SHOW_QUESTION_CMD} {question}'.split() + return subprocess.Popen(cmd) + + +def color_button(layout, text: str, color: str): + """Colorize the button containing the text""" + for button in layout: + if button.get_text() == text: + button.update(button_color=color) + break + + +def prompt_solution(solution: str, prompts=None): + """prompt for the solution""" + if prompts is None: + prompts = ['A', 'B', 'C', 'D', 'E'] + + layout = [[sg.Button(c) for c in prompts]] + window = sg.Window('', layout) + + # Event Loop to process "events" and get the "values" of the inputs + while True: + try: + event, _ = window.read() + except KeyboardInterrupt: + sys.exit(0) + + if event == sg.WIN_CLOSED: # if user closes window or clicks cancel + break + if event == solution: + break + + color_button(layout[0], event, '#FF0000') + play_sound(WRONG_SOUND) + + color_button(layout[0], event, '#00FF00') + play_sound(CORRECT_SOUND) + + window.read() + window.close() + + +def main(): + """main entry point of the quiz""" + + parser = argparse.ArgumentParser() + parser.add_argument('question_dir', + nargs='*', + help='directories containing questions') + parser.add_argument('-s', + '--solution-file', + help='file containing the solutions') + parser.add_argument( + '--select', help='selector string to identify a subset of solutions') + args = parser.parse_args() + + question_dirs = [Path(p) for p in args.question_dir or ['.']] + questions = possible_questions(question_dirs) + + solution_files = [] + if args.solution_file: + solution_files.append(Path(args.solution_file)) + else: + solution_files.extend([p / 'solutions.csv' for p in question_dirs]) + + solutions = load_solutions(question_dirs, solution_files, args.select) + + solved = [] + while questions: + next_question = random.choice(questions) + solution = solutions[f'{next_question.parent / next_question.stem}'] + print(f'{next_question}: {solution}', end='') + + show_question(next_question) + prompt_solution(solution) + terminate_subprocesses() + + solved.append(next_question) + questions.remove(next_question) + print(' Done.') + + print(f'Solved: {solved}') + + +if __name__ == '__main__': + main() |
