7
\$\begingroup\$

To improve my Python knowledge I started a small coding project: A telegram chat/quiz bot that is based on official questions about basketball rules. The bot reads them from one or multiple .csv files.

The bot has two modes:

  • Exercise mode: Questions are asked until the user selects to see the results. After each answer of the user, the correct answer and reason is displayed.
  • Test mode: The user is asked 21 questions. After all answers are given the bot shows the result and displays wrong answers together with the correct answer and reasoning.

Questions may have an image associated with them, but the question text is currently included in the image itself.

I tried to seperate game logic and chat interaction as good as possible, but failed somewhat.

I'm glad for any feedback. Thanks in advance!

Known Limitations

  • Currently no logging, but feedback on that point is still appreciated.
  • The layout of the .csv files is not easily changable, as they are from an external source.

Code

bot.py

import csv
import datetime
import logging
import os
import random
from logging import debug, info
from pathlib import Path

import telegram
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import (CallbackContext, CallbackQueryHandler,
                          CommandHandler, ConversationHandler, Filters,
                          MessageHandler, Updater)

from game import Game
from question import Question
from question_catalogue import QuestionCatalogue

logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
                    level=logging.INFO)
__location__ = os.path.realpath(
    os.path.join(os.getcwd(), os.path.dirname(__file__)))
# read bot api token from file
TOKEN_FILE = os.path.join(__location__, 'token.txt')
TOKEN = Path(TOKEN_FILE).read_text().strip()

TEST_QUESTION_COUNT = 21
MAX_ERRORS = 6
MAX_ERRORS_PERCENTAGE = MAX_ERRORS / TEST_QUESTION_COUNT


def start(update: Update, context: CallbackContext) -> None:
    """Called when starting the bot or when finishing a game. Shows short "How To".

    Args:
        update (Update): Telegram Update
        context (CallbackContext): Telegram Context
    """
    global catalogue
    context.bot.send_message(
        text="🏀 *Willkommen zum Basketball Regelquiz Bot*\n"
        + f"Dieser Bot basiert auf dem aktuellen Regel\- und Kampfrichterfragenkatalog des DBB "
        + f"\({catalogue.catalogue_name(True)}\)\ "
        + f"mit insgesamt {catalogue.question_count()} Fragen\. \n"
        + "Hier erklären wir dir kurz wie\'s geht: \n"
        + "Nutze den _Übungsmodus_ um dir einzelne Fragen anzeigen zu lassen\. "
        + "Nach jeder Frage siehst du direkt die richtige Antwort und Begründung\. \n"
        + f"Beim _Regeltest_ bekommst du {TEST_QUESTION_COUNT} Fragen gestellt, von denen du maximal {MAX_ERRORS} falsch beantworten darfst\.",
        chat_id=update.effective_chat.id,
        parse_mode='MarkdownV2',
        reply_markup=telegram.ReplyKeyboardRemove()
    )

    keyboard = [
        [InlineKeyboardButton("📚 Übungsmodus", callback_data='exercise')],
        [InlineKeyboardButton("📝 Regeltest", callback_data='test')],
    ]
    reply_markup = InlineKeyboardMarkup(keyboard)

    update.message.reply_text(
        'Bitte wähle den gewünschten Modus aus:', reply_markup=reply_markup)


def ask_question(update: Update, context: CallbackContext):
    """Displays the next question from the game and displays the ReplyKeyboard.

    Args:
        update (Update): Telegram Update
        context (CallbackContext): Telegram Context
    """
    try:
        context.user_data["game"]
    except KeyError:
        help_command(update, context)
        return
    try:
        question_data = context.user_data["game"].show_question()
    except IndexError:
        # no more questions available
        context.bot.send_message(chat_id=update.effective_chat.id,
                                 text="Du hast alle verfügbaren Fragen in diesem Durchlauf bereits beantwortet\.",
                                 parse_mode=telegram.ParseMode.MARKDOWN_V2)
        show_result(update, context)
        return

    reply_markup = telegram.ReplyKeyboardMarkup([['Ja'], ['Nein']], True)

    context.bot.send_message(chat_id=update.effective_chat.id,
                             text=question_data[0],
                             parse_mode=telegram.ParseMode.MARKDOWN_V2,
                             reply_markup=reply_markup)

    if question_data[1] != None:
        # question is image question
        context.bot.sendPhoto(chat_id=update.effective_chat.id,
                              photo=open(question_data[1], 'rb'),
                              parse_mode=telegram.ParseMode.MARKDOWN_V2)


def show_result(update: Update, context: CallbackContext):
    """Shows result of game to user including the evaluation. Presents options to play again

    Args:
        update (Update): Telegram Update
        context (CallbackContext): Telegram Context
    """
    try:
        context.user_data["game"]
    except KeyError:
        help_command(update, context)
        return
    if not context.user_data["game"].finished():
        # only show results if game is finished
        return

    result = context.user_data["game"].result()
    result_texts = result[0]
    questions = result[1]

    first = True
    reply_markup = telegram.ReplyKeyboardMarkup([['/start']], True)

    for text in result_texts:
        context.bot.send_message(chat_id=update.effective_chat.id,
                                 text=text,
                                 parse_mode=telegram.ParseMode.MARKDOWN_V2,
                                 reply_markup=reply_markup)
        if first:
            reply_markup = None
    # show wrong answers and their reason
    for question in questions:
        if question.image_question():
            # question is image question, show question number before image
            context.bot.send_message(chat_id=update.effective_chat.id,
                                     text=question.question_header(),
                                     parse_mode=telegram.ParseMode.MARKDOWN_V2)
            # reason as caption below image
            context.bot.sendPhoto(chat_id=update.effective_chat.id,
                                  photo=open(question.path, 'rb'),
                                  caption=question.reason_string(),
                                  parse_mode=telegram.ParseMode.MARKDOWN_V2)
        else:
            # one message containing all
            context.bot.send_message(chat_id=update.effective_chat.id,
                                     text=question.full_question_string(),
                                     parse_mode=telegram.ParseMode.MARKDOWN_V2)

    context.bot.send_message(chat_id=update.effective_chat.id,
                             text="🔁 Benutze /start um ein neues Quiz zu starten\.",
                             parse_mode=telegram.ParseMode.MARKDOWN_V2,
                             reply_markup=reply_markup)


def button(update: Update, context: CallbackContext) -> None:
    """Handles inline keyboard presses.

    Args:
                update (Update): Telegram Update
        context (CallbackContext): Telegram Context
    """
    query = update.callback_query

    # CallbackQueries need to be answered, even if no notification to the user is needed
    # Some clients may have trouble otherwise. See https://core.telegram.org/bots/api#callbackquery
    query.answer()

    global catalogue

    if query.data == "exercise":
        mode = query.data
        query.edit_message_text(text=f"📚 Übungsmodus ausgewählt")
        context.user_data["game"] = Game(query.data, catalogue)
        ask_question(update, context)
    elif query.data == "test":
        mode = query.data
        query.edit_message_text(text=f"📝 Testmodus ausgewählt")
        context.user_data["game"] = Game(
            query.data, catalogue, TEST_QUESTION_COUNT, MAX_ERRORS_PERCENTAGE)
        ask_question(update, context)
    elif query.data == "next":
        query.edit_message_text(text=f"⏭️ Nächste Frage:")
        ask_question(update, context)
    elif query.data == "result":
        query.edit_message_text(text=f"📚 Übungsmodus beendet")
        show_result(update, context)


def exercise_options_handler(update: Update, context: CallbackContext) -> None:
    """Removes inline keyboard and proceeds with next question.

    Args:
        update (Update): Telegram Update
        context (CallbackContext): Telegram Context
    """
    if context.match.group(0).lower() == "nächste frage":
        ask_question(update, context)
    else:
        show_result(update, context)


def answer_handler(update: Update, context: CallbackContext) -> None:
    """Handles user answers based on chat message. Shows inline keyboards for next options or shows result.

    Args:
        update (Update): Telegram Update
        context (CallbackContext): Telegram Context
    """
    try:
        context.user_data["game"]
    except KeyError:
        help_command(update, context)
        return

    if context.match.group(0).lower() == "ja":
        context.user_data["game"].add_answer(True)
    else:
        context.user_data["game"].add_answer(False)

    if context.user_data["game"].mode == "exercise":
        # show answer validation
        for text in context.user_data["game"].check_answer():
            context.bot.send_message(chat_id=update.effective_chat.id,
                                     text=text,
                                     parse_mode=telegram.ParseMode.MARKDOWN_V2,
                                     reply_markup=telegram.ReplyKeyboardMarkup(
                                         [['Nächste Frage'], ['Auswertung']], True
                                     ))

    if context.user_data["game"].mode == "exercise":
        keyboard = [
            [InlineKeyboardButton("⏭️ Nächste Frage",
                                  callback_data='next')],
            [InlineKeyboardButton("📊 Auswertung", callback_data='result')],
        ]
        reply_markup = InlineKeyboardMarkup(keyboard)
        update.message.reply_text(
            'Wie möchtest du fortfahren:', reply_markup=reply_markup)
    else:
        if not context.user_data["game"].finished():
            ask_question(update, context)
        else:
            show_result(update, context)


def help_command(update: Update, context: CallbackContext) -> None:
    """Displays help message if a command is unknown

    Args:
        update (Update): Telegram Update
        context (CallbackContext): Telegram Context
    """
    update.message.reply_text("Benutze /start um den Bot zu starten.")


def main():
    """Creates question catalogue and starts bot
    """
    global catalogue

    catalogue = QuestionCatalogue(2020, 2)
    catalogue.add_questions_from_file(
        os.path.join(__location__, '../resources/rules.csv'))
    catalogue.add_questions_from_file(
        os.path.join(__location__, '../resources/kampf.csv'))
    catalogue.add_questions_from_file(
        os.path.join(__location__, '../resources/image-questions.csv'), True)

    updater = Updater(
        token=TOKEN, use_context=True)
    dispatcher = updater.dispatcher

    updater.dispatcher.add_handler(CommandHandler('start', start))
    updater.dispatcher.add_handler(CallbackQueryHandler(button))
    updater.dispatcher.add_handler(MessageHandler(
        Filters.regex("^(?:ja|Ja|nein|Nein)$"), answer_handler))
    updater.dispatcher.add_handler(MessageHandler(
        Filters.regex("^(?:Nächste Frage|Auswertung)$"), exercise_options_handler))
    updater.dispatcher.add_handler(CommandHandler('help', help_command))

    updater.start_polling()

    updater.idle()


if __name__ == '__main__':
    main()


game.py

import datetime

import telegram

from question import Question
from question_catalogue import QuestionCatalogue
from quiz import Quiz

# settings for default test
TEST_QUESTION_COUNT = 21
MAX_ERRORS = 6
MAX_ERRORS_PERCENTAGE = MAX_ERRORS / TEST_QUESTION_COUNT


class Game:

    def __init__(self, mode: str, question_catalogue: QuestionCatalogue, questions_count: int = TEST_QUESTION_COUNT, max_error_percentage: float = MAX_ERRORS_PERCENTAGE):
        """Creates a new game with the given question catalogue and starts the timer for this game.

        Args:
            mode (str): Either "test" or "exercise"
            question_catalogue (QuestionCatalogue): The questions to be used in this game
            questions_count (int, optional): The number of questions to ask. Ignored if mode is "exercise". Defaults to TEST_QUESTION_COUNT.
            max_error_percentage (float, optional): Maximum percentage of wrongly answered questions to pass. Defaults to MAX_ERRORS_PERCENTAGE.
        Raises:
            ValueError: If the mode is not either "test" or "exercise".
        """
        self.mode = mode
        self.question_catalogue = question_catalogue
        if self.mode == "test":
            self.quiz = Quiz(self.question_catalogue, questions_count)
        elif self.mode == "exercise":
            self.quiz = Quiz(self.question_catalogue)
        else:
            raise ValueError
        self.timer = datetime.datetime.now().replace(microsecond=0)
        self.max_error_percentage = max_error_percentage

    def finished(self) -> bool:
        """Check whether this game is finished.

        Returns:
            bool: True if finished, False otherwise.
        """
        return self.quiz.finished()

    def time_elapsed(self) -> datetime.timedelta:
        """Calculates time passed from start/creation of the game until now.

        Returns:
            timedelta: Time passed since start of the game
        """
        return datetime.datetime.now().replace(microsecond=0) - self.timer

    def show_question(self) -> str:
        """Returns the next question formatted as markdown string containing question number and the question text.

        Returns:
            str: The formatted question string
        """
        question = self.quiz.get_next_question()

        text = (f"❓ *Frage {self.quiz.get_current_question_number()}/{self.quiz.question_count()} "
                f"\({question.full_number(True)}\)*:\n "
                f"{telegram.utils.helpers.escape_markdown(question.question_text, 2)}")
        return (text, question.path)

    def add_answer(self, answer: bool) -> None:
        """Adds the users answer for the current question.

        Args:
            answer (bool): The users answer
        """
        self.quiz.add_answer(answer)

    def check_answer(self) -> [str]:
        """Stores users answer,  returns feedback and the correct reasoning. Only used in exercise mode.

        Returns:
            [str]: Array of markdown formatted strings
        """
        if self.mode == "exercise":
            message = []
            message.append(self.quiz.check_answer())
            message.append(self.quiz.get_current_question().reason_string())
            return message

    def result(self) -> [str]:
        """Returns feedback and evaluation of the current game. Does nothing when in test mode and the game is not finished yet.
            Contains all wrongly answered questions and the correct reasoning in test mode.
        Returns:
            [str]: Array of markdown formatted strings containing error percentages, evaluation and time elapsed.
        """
        if self.mode == "test" and (not self.quiz.finished()):
            return

        result = []
        questions = []
        result.append((f"📊 *Auswertung* \n"
                       f"_Fehler_: {self.quiz.get_wrong_answer_count()} von {self.quiz.question_count()}"
                       f"\({self.quiz.get_wrong_percentage_string(True)}\) \n"
                       f"_Ergebnis_: {self.quiz.result(self.max_error_percentage)} \n"
                       f"_Benötigte Zeit_: {self.time_elapsed()}⏲️"))

        if self.mode == "test":
            result.append("Folgende Fragen wurden _falsch_ beantwortet:")
            if self.quiz.get_wrong_answers():
                # add wrong questions and their reason
                questions = [question['question']
                             for question in self.quiz.get_wrong_answers()]
            else:
                # no wrong answer, congratulations!
                result.append("_🎉 keine 🎉_")
        return (result, questions)


quiz.py

import random

import telegram

from question import Question
from question_catalogue import QuestionCatalogue


class Quiz:

    def __init__(self, question_catalogue: QuestionCatalogue, question_count: int = 0):
        """Creates a new quiz based on the given question catalogue

        Args:
            question_catalogue (QuestionCatalogue): Questions to randomly choose from.
            question_count (int, optional): Populate the quiz with question_count questions. Must be greater or equal to 0.
                If 0 is given, "exercise mode" is used.
                This mode always appends new questions to the quiz when the next question is asked. Defaults to 0.

        Raises:
            ValueError: If the question_count is negative.
        """
        self.question_catalogue = question_catalogue
        if question_count < 0:
            raise ValueError
        # do not select more questions than available
        self.questions = [{"question": question, "answer": None} for question in random.sample(
            self.question_catalogue.questions, min(question_count, question_catalogue.question_count()))]
        self.current_question_index = -1

    def question_count(self) -> int:
        """Current number of questions int this quiz.

        Returns:
            int: Number of questions
        """
        return len(self.questions)

    def get_current_question(self) -> Question:
        """Returns the active question. This is usually the first unanswered question.

        Returns:
            Question: The current active question
        """
        return self.questions[self.current_question_index]['question']

    def check_answer(self, question: Question = None) -> str:
        """Checks and stores the answer for the current active question or a specified question.

        Args:
            question (Question, optional): Question to check the answer for. If None is given, the current question is assumed. Defaults to None.

        Returns:
            str: String containing feedback if the question was answered correctly.
        """
        index = self.current_question_index
        if question != None:
            index = self.questions.index(
                {"question": question, "answer": None})
        if self.questions[index]['answer'] == self.questions[index]['question'].answer:
            return "✅ Richtig\! 🎉"
        else:
            return "❌ Leider falsch 😔"

    def get_current_question_number(self) -> int:
        """Returns the number of the question in this quiz. 1-based indexed.

        Returns:
            int: The 1-based index of the current active question
        """
        return self.current_question_index + 1

    def finished(self) -> bool:
        """Returns whether this quiz is finished (there are no unanswered questions) or not.

        Returns:
            bool: True if quiz is finished. False otherwise.
        """
        # finished if there are no unanswered questions
        return len(list(filter(lambda el: (el['answer'] == None), self.questions))) == 0

    def add_unique_question(self) -> None:
        """Appends a unique question from the catalogue to the quiz.
        Raises:
            IndexError: If the quiz already contains all questions from the catalogue
        """
        current_questions = set(x['question'] for x in self.questions)
        # calculate difference of the two lists to avoid adding duplicates
        available_questions = list(
            set(self.question_catalogue.questions) - current_questions)
        if available_questions:
            self.questions.append(
                {"question": random.choice(available_questions), "answer": None})

    def get_next_question(self) -> Question:
        """Returns the next question of the quiz.
        Sets the active question accordingly.
        If the quiz is already finished (usually in exercise mode) a new question is appended and then returned.

        Returns:
            Question: The new current active question.
        Raises:
            IndexError: If the quiz already contains all questions from the catalogue
        """
        if self.finished():
            self.add_unique_question()

        if self.current_question_index == -1 or self.questions[self.current_question_index]['answer'] != None:
            # only go to next question when user answered the current one
            self.current_question_index += 1
        return self.questions[self.current_question_index]['question']

    def add_answer(self, answer: bool, question: Question = None) -> None:
        """Stores an answer for the given question. 

        Args:
            answer (bool): The user submitted answer to the question
            question (Question, optional): The question the answer was submitted for. If None is given, the current active question is assumed. Defaults to None.
        """
        index = self.current_question_index
        if question != None:
            index = self.questions.index(
                {"question": question, "answer": None})
        if self.questions[index]['answer'] == None:
            self.questions[index]['answer'] = answer

    def get_wrong_answers(self) -> list:
        """Returns all wrongly answered questions as a list.

        Returns:
            list: All questions where the users answer does not match the correct answer
        """
        return list(filter(lambda el: (el['answer'] != el['question'].answer), self.questions))

    def get_wrong_answer_count(self) -> int:
        """Returns the number of wrongly answered questions

        Returns:
            int: Number of questions where the users answer does not match the correct answer 
        """
        return len(self.get_wrong_answers())

    def get_wrong_percentage(self) -> float:
        """Computes the percentage of wrong answers based on total question count in this quiz

        Returns:
            float: The percentage of wrongly answered questions
        """
        return self.get_wrong_answer_count() / self.question_count()

    def get_wrong_percentage_string(self, escape_markdown: bool = False) -> str:
        """Formates the percentage as string with two decimals precision, e.g. "66.67%".

        Args:
            escape_markdown (bool, optional): Escape the returned string for use in markdown. Defaults to False.

        Returns:
            str: Formatted percentage string
        """
        percentage = f"{round(self.get_wrong_percentage() * 100, 2)}%"
        if escape_markdown:
            return telegram.utils.helpers.escape_markdown(percentage, 2)
        else:
            return percentage

    def result(self, max_error_percentage: float) -> str:
        """Returns the result (passed or failed) of the quiz as string depending on the allowed error percentage.

        Args:
            max_error_percentage (float): The maximum percentage of wrongly answered questions allowed to pass this quiz.

        Returns:
            str: Result of the as string
        """
        if self.get_wrong_percentage() <= max_error_percentage:
            return "bestanden ✅"
        else:
            return "NICHT BESTANDEN ❌"


question_catalogue.py

import csv
import os

import telegram

from question import Question


class QuestionCatalogue:

    def __init__(self, year: int, version: int, questions: [Question] = []):
        """Creates a new question catalogue, containing zero ore more questions

        Args:
            year (int): The year of publication for this catalogue
            version (int): The version within the publication year
            questions ([Question], optional): Questions to initialize the catalogue with. Defaults to [].
        """
        self.questions = questions
        self.version = version
        self.year = year

    def catalogue_name(self, escape_markdown: bool = False) -> str:
        """The name of the catalogue, composed of year and version, e.g. "2020_V2"

        Args:
            escape_markdown (bool, optional): Escape the resulting name for markdown use. Defaults to False.

        Returns:
            str: The name of the catalogue
        """
        name = self.year
        if self.version != 1:
            name = f"{self.year}_V{self.version}"

        if escape_markdown:
            return telegram.utils.helpers.escape_markdown(name, 2)
        else:
            return name

    def question_count(self) -> int:
        """Returns number of questions in this catalogue

        Returns:
            int: Number of questions
        """
        return len(self.questions)

    def add_questions_from_file(self, filename: str, image_mode: bool = False):
        """Import questions from a csv-formatted file.
        Lines must be formatted according to the following format: "Nr.;Frage;J;N;Antwort;Art.;"
        If image_mode is used the format is as follows: "Nr.;Frage;J;N;Antwort;Art.;Path;"
        Args:
            filename (str): The csv file to read from. Uses ";" as delimiter.
            image_mode (bool, optional): The input file contains image questions, additional path column to read. Defaults to False.
        """
        with open(filename, newline='') as csvfile:
            reader = csv.DictReader(csvfile, delimiter=';')
            for row in reader:
                if row['J'].lower() == 'x':
                    answer = True
                else:
                    answer = False
                if not('K-' in row['Nr.'] or 'R-' in row['Nr.']):
                    break
                # extract question type and number
                parts = row['Nr.'].split('-')
                question_type = parts[0]
                number = parts[1]
                path = None
                if image_mode:
                    # construct full path to image
                    path = os.path.join(os.path.dirname(filename), row['Path'])
                # create question
                self.questions.append(Question(question_type, number,
                                               row['Frage'], row['Antwort'], row['Art.'], answer, path))

question.py

import telegram


class Question:

    def __init__(self, question_type: str, number: int, question_text: str, reason_text: str, article: str, answer: bool, path: str = None):
        """Creates a new question object

        Args:
            question_type (str): Type of the question, mostly "R" for rule questions or "K" for questrions for table officials.
            number (int): The number of the question
            question_text (str): The question itself
            reason_text (str): The reasoning for the correct answer
            article (str): The article or interpretation on which the question is based
            answer (bool): The correct answer
            path (str, optional): The path to an image, if the question has one. Defaults to None.
        """
        self.question_type = question_type
        self.number = number
        self.question_text = question_text
        self.reason_text = reason_text
        self.article = article
        self.answer = answer
        self.path = path

    def image_question(self) -> bool:
        """Determines whether this question has an image and is therefore an image question or not.

        Returns:
            bool: True, if the question is an image question. False otherwise.
        """
        return self.path != None

    def full_number(self, escape_markdown: bool = False) -> str:
        """Returns the complete question number, composed of the question type and the question number

        Args:
            escape_markdown (bool, optional): Escape the returned string for use in markdown. Defaults to False.

        Returns:
            str: Complete question number, e.g. "R-42" or "K-9"
        """
        text = self.question_type + "-" + self.number
        if escape_markdown:
            return telegram.utils.helpers.escape_markdown(text, 2)
        else:
            return text

    def question_header(self) -> str:
        """Returns markdown formatted header for questions containing the full question number only

        Returns:
            str: Header string , markdown escaped
        """
        return f"*❓ Frage {telegram.utils.helpers.escape_markdown(self.full_number(), 2)}*: \n"

    def full_question_string(self) -> str:
        """Returns markdown escaped string containing question number, question text and reason

        Returns:
            str: The resulting question, markdown escaped
        """
        return (f"{self.question_header()}"
                f"{telegram.utils.helpers.escape_markdown(self.question_text, 2)}\n"
                f"{self.reason_string()}")

    def reason_string(self) -> str:
        """Returns markdown escaped reasoning string

        Returns:
            str: Reason string, markdown escaped
        """
        return (f"ℹ️ *Begründung*: \n"
                f"{telegram.utils.helpers.escape_markdown(self.reason_text, 2)}\n")


Sample .csv file for non-image questions

Nr.;Frage;J;N;Antwort;Art.;
R-42;This is a sample question and not an actual question. Correct?;;x;No (Art. 4). Sample questions are illegal according to article 4.;4;

Sample .csv file for image questions, mostly no question text

Nr.;Frage;J;N;Antwort;Art.;Path;
K-9;;x;;Yes (KRHB);KRHB;./images/K-9.png;
\$\endgroup\$
5
\$\begingroup\$

Overall it seems pretty reasonable; some specific points:

Do not use the root logger. For a non-trivial application such as this, make your own logger instance for each module. You can configure the format of the root logger from the entry point, but don't do that at the global level - be courteous to people importing your code that may want to log differently.

Avoid making a global catalogue. Pass it around by method parameters.

From what I see here you've done a great job in separating implementation language (English) from locale (German). Given that there is only one hard-coded locale, consider calling into https://docs.python.org/3.9/library/locale.html to either assert that the locale is indeed German, or to simply set it as such. Which you should use depends on details of deployment.

The only place where there's been some locale leakage is here:

    os.path.join(__location__, '../resources/rules.csv'))
    os.path.join(__location__, '../resources/kampf.csv'))

Those filenames should standardize on language.

Rather than != None use is not None, since None is a singleton in Python.

Avoid logic-by-exception; this:

    try:
        context.user_data["game"]
    except KeyError:
        help_command(update, context)

should simply be

if 'game' not in context.user_data:
    help_command(update, context)

Use tuple unpacking to convert this:

result_texts = result[0]
questions = result[1]

to

result_texts, questions = result

Your loop flag first can go away; this:

first = True
for text in result_texts:
    context.bot.send_message(..., reply_markup=reply_markup)
    if first:
        reply_markup = None

is more simply expressed as

for text in result_texts:
    context.bot.send_message(..., reply_markup=reply_markup)
    reply_markup = None

This:

        message = []
        message.append(self.quiz.check_answer())
        message.append(self.quiz.get_current_question().reason_string())

should use list literal syntax:

message = [
    self.quiz.check_answer(),
    self.quiz.get_current_question().reason_string(),
]

and its docstring lies when it calls that an array. It's not an array; it's a list.

More broadly, you have a data representation problem. Whereas you have reasonable Quiz and QuestionCatalogue classes, Quiz.questions is a list of dictionaries. This is an instance of early serialization and should be refactored to a list of class instances.

Methods like get_current_question_number are well-represented as @property functions.

len(list(filter(lambda el: (el['answer'] == None), self.questions))) == 0

can be simplified to

all(el.answer is not None for el in self.questions)

assuming that the above dict-to-class refactoring is done.

get_next_question, along with your initially-negative-one-counter, betray an aspect of your Quiz class that would be better-represented as a normal Python iterator. In other words, you can make the class an iterable over its questions, and dispense with current_question_index, question_count, get_current_question, and get_next_question; replacing them with the standard iterator methods. Read https://docs.python.org/3/library/stdtypes.html#iterator-types and https://docs.python.org/3/reference/datamodel.html for information on __next__ and __len__ .

\$\endgroup\$
2
\$\begingroup\$

__location__

Here's a recommendation I have also started implementing recently: Using a definitions.py (naming up to preference of course) file at project level that exports all the references to directories and files I might need. I feel it significantly improved my codebases. Here's an example from one of my projects:

from pathlib import Path
import sys

running_from_executable = getattr(sys, "frozen", False)

if running_from_executable:
    ROOT_DIR = Path(sys.executable).parent
    DATA_DIR = Path(sys._MEIPASS).joinpath("data")

else:
    ROOT_DIR = Path(__file__).parent
    DATA_DIR = ROOT_DIR.joinpath("data")

CONFIG_DIR = ROOT_DIR.joinpath("config")

LOG_FILE = CONFIG_DIR.joinpath("debug.log")
CONFIG_FILE = CONFIG_DIR.joinpath("config.json")

ICONS_DIR = DATA_DIR.joinpath("icons")
FONTS_DIR = DATA_DIR.joinpath("fonts")

Checking if the script is run from an executable might not be relevant for you. As you can see ROOT_DIR would be the equivalent for your global variable __location__. You could also provide the paths to TOKEN_FILE, the resources directory and the various question.csv files from that module in your project. In the current state of your code some paths are hardcoded in main(), others are assigned at module level in main.py, which makes them harder to find and change.

I would also recommend either sticking to os.path or pathlib.Path when handling file system paths instead of mixing them. I personally prefer pathlib.Path.

Here's what it could look like for your project. This represents my understanding of your project structure, if you decide to use it please make sure to double check the paths.

from pathlib import Path

SCRIPT_DIR = Path(__file__).parent
PROJECT_DIR = SCRIPT_DIR.parent

RESOURCES_DIR = PROJECT_DIR.joinpath("resources")

TOKEN_FILE = PROJECT_DIR.joinpath("token.txt")

RULES_CSV = RESOURCES_DIR.joinpath("rules.csv")
KAMPF_CSV = RESOURCES_DIR.joinpath("kampf.csv")
IMAGE_QUESTIONS_CSV = RESOURCES_DIR.joinpath("image-questions.csv")

QUESTIONS_CSV_FILES = RULES_CSV, KAMPF_CSV, IMAGE_QUESTIONS_CSV
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.