Чтобы улучшить свои знания 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 ответа
В целом это кажется довольно разумным; некоторые конкретные моменты:
Не используйте корневой регистратор. Для такого нетривиального приложения, как это, создайте свой собственный экземпляр регистратора для каждого модуля. Вы можете настроить формат корневого регистратора из точки входа, но не делайте этого на глобальном уровне — будьте вежливы с людьми, импортирующими ваш код, которые могут захотеть вести журнал иначе.
Избегайте создания глобального 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