#!/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 . """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()