Бот для викторины Python Telegram

Чтобы улучшить свои знания Python, я начал небольшой проект по программированию: бот для телеграмм-чата / викторины, основанный на официальных вопросах о правилах баскетбола. Бот читает их с одного или нескольких .csv файлы.

У бота есть два режима:

  • Режим упражнений: вопросы задаются до тех пор, пока пользователь не выберет просмотр результатов. После каждого ответа пользователя отображается правильный ответ и причина.
  • Тестовый режим: пользователю задается 21 вопрос. После того, как все ответы даны, бот показывает результат и отображает неправильные ответы вместе с правильным ответом и аргументацией.

С вопросами может быть связано изображение, но в настоящее время текст вопроса включен в само изображение.

Я попытался разделить игровую логику и взаимодействие в чате как можно лучше, но у меня получилось немного.

Рад любым отзывам. Заранее спасибо!

Известные ограничения

  • В настоящее время ведение журнала не ведется, но обратная связь по этому поводу все еще приветствуется.
  • Макет .csv файлы не так легко изменить, так как они поступают из внешнего источника.

Код

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")


Образец .csv файл для не изображение вопросов

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;

Образец .csv файл для вопросы изображения, в основном без вопросов

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

2 ответа
2

В целом это кажется довольно разумным; некоторые конкретные моменты:

Не используйте корневой регистратор. Для такого нетривиального приложения, как это, создайте свой собственный экземпляр регистратора для каждого модуля. Вы можете настроить формат корневого регистратора из точки входа, но не делайте этого на глобальном уровне — будьте вежливы с людьми, импортирующими ваш код, которые могут захотеть вести журнал иначе.

Избегайте создания глобального catalogue. Передайте его по параметрам метода.

Из того, что я вижу здесь, вы проделали отличную работу по отделению языка реализации (английского) от локали (немецкого). Учитывая, что существует только один жестко запрограммированный языковой стандарт, рассмотрите возможность вызова https://docs.python.org/3.9/library/locale.html либо утверждать, что язык действительно немецкий, либо просто установить его как таковой. Что вы должны использовать, зависит от деталей развертывания.

Единственное место, где произошла утечка локали, — это здесь:

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

Эти имена файлов должны соответствовать языку.

Скорее, чем != None использовать is not None, поскольку None это синглтон в Python.

Избегайте логики за исключением; это:

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

должно быть просто

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

Используйте распаковку кортежей, чтобы преобразовать это:

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

к

result_texts, questions = result

Ваш флаг цикла first может уйти; это:

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

проще выражается как

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

Этот:

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

следует использовать синтаксис литерала списка:

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

и его строка документации лежит, когда он вызывает это массив. Это не массив; это список.

В более широком смысле у вас есть проблема с представлением данных. В то время как у вас есть разумные Quiz а также QuestionCatalogue классы Quiz.questions это список словарей. Это экземпляр ранней сериализации, и его необходимо преобразовать в список экземпляров класса.

Такие методы, как get_current_question_number хорошо представлены как @property функции.

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

можно упростить до

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

предполагая, что приведенный выше рефакторинг dict-to-class выполнен.

get_next_questionвместе с вашим изначально отрицательным счетчиком, предать аспект вашего Quiz класс, который лучше представить как обычный итератор Python. Другими словами, вы можете сделать класс итеративным по его вопросам и отказаться от current_question_index, question_count, get_current_question, а также get_next_question; заменяя их стандартными методами итератора. Читать https://docs.python.org/3/library/stdtypes.html#iterator-types а также https://docs.python.org/3/reference/datamodel.html для информации о __next__ а также __len__ .

    __location__

    Вот рекомендация, которую я недавно начал реализовывать: definitions.py (именование, конечно, в соответствии с предпочтениями) на уровне проекта, который экспортирует все ссылки на каталоги и файлы, которые могут мне понадобиться. Я чувствую, что это значительно улучшило мою кодовую базу. Вот пример из одного из моих проектов:

    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")
    

    Проверка того, запускается ли сценарий из исполняемого файла, может быть для вас не актуальна. Как вы видете ROOT_DIR будет эквивалентом вашей глобальной переменной __location__. Вы также можете указать пути к TOKEN_FILE, то Ресурсы справочник и различные вопрос.csv файлы из этого модуля в вашем проекте. В текущем состоянии вашего кода некоторые пути жестко запрограммированы в main(), другие назначаются на уровне модуля в main.py, из-за чего их труднее найти и изменить.

    Я также рекомендовал бы придерживаться os.path или же pathlib.Path при обработке путей файловой системы вместо их смешивания. Я лично предпочитаю pathlib.Path.

    Вот как это может выглядеть для вашего проекта. Это отражает мое понимание структуры вашего проекта, если вы решите его использовать, не забудьте дважды проверить пути.

    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
    

      Добавить комментарий

      Ваш адрес email не будет опубликован. Обязательные поля помечены *