Skip to main content
4 of 10
Don't repeat tags in title
Toby Speight
  • 81.8k
  • 14
  • 101
  • 309

Quiz interface based on Yaml files

I am training for a Symfony certificate. I found a Github repository providing questions to prepare for the exam. The repository contains a data directory with a bunch of questions mapped by topic in Yaml format.

Because raw yaml files are not user-friendly to train with, I made a quick Python script providing a user-interface which displays the questions and allows you to answer.

The script allows the user to select a topic and the amount of questions to load from that topic. The questions are loaded randomly and dynamically from the files. After the user answered all the questions a table is provided giving an overview of the correct answers and the score.

Python3 quiz interface 1 Python3 quiz interface 2

The code is fully functional but I am wondering if improvements can be made.

  • Can the readability be improved?
  • Can the code structure be improved?
  • Am I following the PEP styling guide?
  • Can some snippets be shortened?
import os
import random
import yaml
from prettytable import PrettyTable

DATA_FOLDER = 'data/'

class Styling:
    GREEN = '\033[92m'
    YELLOW = '\033[33m'
    RED = '\033[91m'
    ENDC = '\033[0m'
    BOLD = '\033[1m'
    ENDBOLD = '\033[0m'

class CLIHandler:
    @staticmethod
    def display_message(message, color='', bold=False):
        print(f'{color}{Styling.BOLD if bold else ""}{message}{Styling.ENDBOLD if bold else ""}{Styling.ENDC}')
    
    @staticmethod
    def display_topics(topics):
        CLIHandler.display_message('-- SYMFONY CERTIFICATE PRACTICE --\n', Styling.GREEN, True)
        for i, topic in enumerate(topics):
            CLIHandler.display_message(f'{i + 1}. {topic.name}')

    @staticmethod
    def display_question(question, question_number):
        CLIHandler.display_message(f'\n{Styling.YELLOW}Question #{question_number}{Styling.ENDC} {Styling.BOLD}{question}{Styling.ENDBOLD}', bold=True)

    @staticmethod
    def display_table(field_names, rows, align='l'):
        table = PrettyTable()
        table.align = align
        table.field_names = [f'{Styling.BOLD}{Styling.GREEN}{field}{Styling.ENDC}{Styling.ENDBOLD}' for field in field_names]
        for row in rows:
            table.add_row(row)
        print('\n', table)

    @staticmethod
    def input_message(message):
        return input(f'{Styling.BOLD}{message}: {Styling.ENDBOLD} ')

    @staticmethod
    def clear_screen():
        os.system('clear')

class Topic:
    def __init__(self, name, path):
        self.name = name
        self.path = path
        self.questions = []

    def load_topic(self, amount_of_questions):
        while len(self.questions) < amount_of_questions:
            question = self.__load_random_question()
            if question not in self.questions:
                self.questions.append(question)

    def show_questions(self):
        for i, question in enumerate(self.questions):
            question.show_question(i + 1)
            try:
                answers = input('> ').split(',')
                question.select_answers(answers)
            except (ValueError, IndexError):
                CLIHandler.display_message('Invalid input. Skipping...', Styling.RED)

    def show_results(self):
        rows = []
        for i, question in enumerate(self.questions):
            question_str = question.question[:75] + (question.question[:75] and '..')
            correct_answers = question.correct_answers()
            your_answers = question.selected_answers()
            result = '✅' if set(correct_answers) == set(your_answers) else '❌'
            rows.append([
                f'{Styling.YELLOW}#{i + 1}{Styling.ENDC} {question_str}',
                ', '.join([answer.value[:35] + (answer.value[:35] and '..') for answer in correct_answers]),
                result
            ])

        correct_count = self.__correct_answers_count()
        CLIHandler.display_table(
            field_names=['Question', 'Correct Answer', 'Result'],
            rows=rows
        )
        CLIHandler.display_message(
            f'{Styling.BOLD}Score:{Styling.ENDBOLD} {Styling.RED}Wrong: {len(self.questions) - correct_count}{Styling.ENDC} - '
            f'{Styling.GREEN}Correct: {correct_count}{Styling.ENDC}'
        )

    def __correct_answers_count(self):
        return sum(
            set(question.selected_answers()) == set(question.correct_answers())
            for question in self.questions
        )

    def __load_random_question(self):
        question_file = random.choice([f for f in os.listdir(self.path) if f.endswith('.yaml')])
        with open(self.path + '/' + question_file) as file:
            questions = yaml.safe_load(file)['questions']
            random_question = random.choice(questions)
            question = Question(random_question['uuid'], random_question['question'])
            for answer in random_question['answers']:
                question.add_answer(Answer(answer['value'], answer['correct']))
            
            return question
    
    @staticmethod
    def list_topics():
        return [Topic(topic.lower(), os.path.join(DATA_FOLDER, topic)) for topic in os.listdir(DATA_FOLDER)]

class Question:
    def __init__(self, uuid, question):
        self.uuid = uuid
        self.question = question.replace('\n', ' ').replace('\r', ' ')
        self.answers = []

    def __eq__(self, other):
        if isinstance(other, Question):
            return self.uuid == other.uuid
        return False

    def add_answer(self, answer):
        self.answers.append(answer)

    def show_question(self, question_number):
        CLIHandler.display_question(self.question, question_number)
        for i, answer in enumerate(self.answers):
            CLIHandler.display_message(f'\t[{Styling.GREEN}{i + 1}{Styling.ENDC}] {answer.value}')
    
    def select_answers(self, answers):
        answers = [int(answer) - 1 for answer in answers]
        for answer in answers:
            if 0 <= answer < len(self.answers):
                self.answers[answer].select()
                CLIHandler.display_message(f'{Styling.YELLOW}Your answer: {Styling.ENDC}{self.answers[answer].value}')
            else:
                raise IndexError('Invalid answer selected')

    def selected_answers(self):
        return [answer for answer in self.answers if answer.selected]

    def correct_answers(self):
        return [answer for answer in self.answers if answer.correct]


class Answer:
    def __init__(self, value, correct):
        self.value = value
        self.correct = correct
        self.selected = False

    def select(self):
        self.selected = True

def main():
    topics = Topic.list_topics()
    CLIHandler.display_topics(topics)

    try:
        selected_topic = int(CLIHandler.input_message('\nSelect topic')) - 1
        amount_of_questions = int(CLIHandler.input_message('Amount of questions'))
    except ValueError:
        CLIHandler.display_message('Invalid input. Exiting...', Styling.RED)
        return
    
    if not (0 <= selected_topic < len(topics)):
        CLIHandler.display_message('Invalid input. Exiting...', Styling.RED)
        return

    CLIHandler.clear_screen()
    topic = topics[selected_topic]
    topic.load_topic(amount_of_questions)
    topic.show_questions()
    topic.show_results()

if __name__ == '__main__':
    main()
O'Niel
  • 319
  • 1
  • 7