From 891e987d43a2f4239acf973b109d2e6c291b24ee Mon Sep 17 00:00:00 2001 From: Patai5 Date: Sun, 29 Dec 2024 15:03:35 +0100 Subject: [PATCH 1/6] refactor: move src files to upper level --- src/{bakabot => }/__init__.py | 0 src/{bakabot => }/bot_commands/bot_commands.py | 0 src/{bakabot => }/bot_commands/reactions.py | 0 src/{bakabot => }/constants.py | 0 src/{bakabot => }/core/__init.__py | 0 src/{bakabot => }/core/grades/grade.py | 0 src/{bakabot => }/core/grades/grades.py | 0 src/{bakabot => }/core/grades/parse_grades.py | 0 src/{bakabot => }/core/predictor.py | 0 src/{bakabot => }/core/reminder.py | 0 src/{bakabot => }/core/schedule/day.py | 0 src/{bakabot => }/core/schedule/lesson.py | 0 src/{bakabot => }/core/schedule/parse_schedule.py | 0 src/{bakabot => }/core/schedule/schedule.py | 0 src/{bakabot => }/core/shared_parsers.py | 0 src/{bakabot => }/core/subjects/subject.py | 0 src/{bakabot => }/core/subjects/subjects_cache.py | 0 src/{bakabot => }/core/subjects/utils.py | 0 src/{bakabot => }/core/table.py | 0 src/{bakabot => }/feature_manager/feature_initializer.py | 0 src/{bakabot => }/feature_manager/feature_manager.py | 0 src/{bakabot => }/html2img/css/table.css | 0 src/{bakabot => }/html2img/html2img.py | 0 src/{bakabot => }/main.py | 0 src/{bakabot => }/message_timers.py | 0 src/{bakabot => }/utils/first_time_setup.py | 0 src/{bakabot => }/utils/utils.py | 0 27 files changed, 0 insertions(+), 0 deletions(-) rename src/{bakabot => }/__init__.py (100%) rename src/{bakabot => }/bot_commands/bot_commands.py (100%) rename src/{bakabot => }/bot_commands/reactions.py (100%) rename src/{bakabot => }/constants.py (100%) rename src/{bakabot => }/core/__init.__py (100%) rename src/{bakabot => }/core/grades/grade.py (100%) rename src/{bakabot => }/core/grades/grades.py (100%) rename src/{bakabot => }/core/grades/parse_grades.py (100%) rename src/{bakabot => }/core/predictor.py (100%) rename src/{bakabot => }/core/reminder.py (100%) rename src/{bakabot => }/core/schedule/day.py (100%) rename src/{bakabot => }/core/schedule/lesson.py (100%) rename src/{bakabot => }/core/schedule/parse_schedule.py (100%) rename src/{bakabot => }/core/schedule/schedule.py (100%) rename src/{bakabot => }/core/shared_parsers.py (100%) rename src/{bakabot => }/core/subjects/subject.py (100%) rename src/{bakabot => }/core/subjects/subjects_cache.py (100%) rename src/{bakabot => }/core/subjects/utils.py (100%) rename src/{bakabot => }/core/table.py (100%) rename src/{bakabot => }/feature_manager/feature_initializer.py (100%) rename src/{bakabot => }/feature_manager/feature_manager.py (100%) rename src/{bakabot => }/html2img/css/table.css (100%) rename src/{bakabot => }/html2img/html2img.py (100%) rename src/{bakabot => }/main.py (100%) rename src/{bakabot => }/message_timers.py (100%) rename src/{bakabot => }/utils/first_time_setup.py (100%) rename src/{bakabot => }/utils/utils.py (100%) diff --git a/src/bakabot/__init__.py b/src/__init__.py similarity index 100% rename from src/bakabot/__init__.py rename to src/__init__.py diff --git a/src/bakabot/bot_commands/bot_commands.py b/src/bot_commands/bot_commands.py similarity index 100% rename from src/bakabot/bot_commands/bot_commands.py rename to src/bot_commands/bot_commands.py diff --git a/src/bakabot/bot_commands/reactions.py b/src/bot_commands/reactions.py similarity index 100% rename from src/bakabot/bot_commands/reactions.py rename to src/bot_commands/reactions.py diff --git a/src/bakabot/constants.py b/src/constants.py similarity index 100% rename from src/bakabot/constants.py rename to src/constants.py diff --git a/src/bakabot/core/__init.__py b/src/core/__init.__py similarity index 100% rename from src/bakabot/core/__init.__py rename to src/core/__init.__py diff --git a/src/bakabot/core/grades/grade.py b/src/core/grades/grade.py similarity index 100% rename from src/bakabot/core/grades/grade.py rename to src/core/grades/grade.py diff --git a/src/bakabot/core/grades/grades.py b/src/core/grades/grades.py similarity index 100% rename from src/bakabot/core/grades/grades.py rename to src/core/grades/grades.py diff --git a/src/bakabot/core/grades/parse_grades.py b/src/core/grades/parse_grades.py similarity index 100% rename from src/bakabot/core/grades/parse_grades.py rename to src/core/grades/parse_grades.py diff --git a/src/bakabot/core/predictor.py b/src/core/predictor.py similarity index 100% rename from src/bakabot/core/predictor.py rename to src/core/predictor.py diff --git a/src/bakabot/core/reminder.py b/src/core/reminder.py similarity index 100% rename from src/bakabot/core/reminder.py rename to src/core/reminder.py diff --git a/src/bakabot/core/schedule/day.py b/src/core/schedule/day.py similarity index 100% rename from src/bakabot/core/schedule/day.py rename to src/core/schedule/day.py diff --git a/src/bakabot/core/schedule/lesson.py b/src/core/schedule/lesson.py similarity index 100% rename from src/bakabot/core/schedule/lesson.py rename to src/core/schedule/lesson.py diff --git a/src/bakabot/core/schedule/parse_schedule.py b/src/core/schedule/parse_schedule.py similarity index 100% rename from src/bakabot/core/schedule/parse_schedule.py rename to src/core/schedule/parse_schedule.py diff --git a/src/bakabot/core/schedule/schedule.py b/src/core/schedule/schedule.py similarity index 100% rename from src/bakabot/core/schedule/schedule.py rename to src/core/schedule/schedule.py diff --git a/src/bakabot/core/shared_parsers.py b/src/core/shared_parsers.py similarity index 100% rename from src/bakabot/core/shared_parsers.py rename to src/core/shared_parsers.py diff --git a/src/bakabot/core/subjects/subject.py b/src/core/subjects/subject.py similarity index 100% rename from src/bakabot/core/subjects/subject.py rename to src/core/subjects/subject.py diff --git a/src/bakabot/core/subjects/subjects_cache.py b/src/core/subjects/subjects_cache.py similarity index 100% rename from src/bakabot/core/subjects/subjects_cache.py rename to src/core/subjects/subjects_cache.py diff --git a/src/bakabot/core/subjects/utils.py b/src/core/subjects/utils.py similarity index 100% rename from src/bakabot/core/subjects/utils.py rename to src/core/subjects/utils.py diff --git a/src/bakabot/core/table.py b/src/core/table.py similarity index 100% rename from src/bakabot/core/table.py rename to src/core/table.py diff --git a/src/bakabot/feature_manager/feature_initializer.py b/src/feature_manager/feature_initializer.py similarity index 100% rename from src/bakabot/feature_manager/feature_initializer.py rename to src/feature_manager/feature_initializer.py diff --git a/src/bakabot/feature_manager/feature_manager.py b/src/feature_manager/feature_manager.py similarity index 100% rename from src/bakabot/feature_manager/feature_manager.py rename to src/feature_manager/feature_manager.py diff --git a/src/bakabot/html2img/css/table.css b/src/html2img/css/table.css similarity index 100% rename from src/bakabot/html2img/css/table.css rename to src/html2img/css/table.css diff --git a/src/bakabot/html2img/html2img.py b/src/html2img/html2img.py similarity index 100% rename from src/bakabot/html2img/html2img.py rename to src/html2img/html2img.py diff --git a/src/bakabot/main.py b/src/main.py similarity index 100% rename from src/bakabot/main.py rename to src/main.py diff --git a/src/bakabot/message_timers.py b/src/message_timers.py similarity index 100% rename from src/bakabot/message_timers.py rename to src/message_timers.py diff --git a/src/bakabot/utils/first_time_setup.py b/src/utils/first_time_setup.py similarity index 100% rename from src/bakabot/utils/first_time_setup.py rename to src/utils/first_time_setup.py diff --git a/src/bakabot/utils/utils.py b/src/utils/utils.py similarity index 100% rename from src/bakabot/utils/utils.py rename to src/utils/utils.py From 52dad61f938ede551484f0a0de1b41c10f609b5a Mon Sep 17 00:00:00 2001 From: Patai5 Date: Sun, 29 Dec 2024 15:05:59 +0100 Subject: [PATCH 2/6] refactor: fix linting, typing, and relative imports --- src/bot_commands/bot_commands.py | 63 +++---- src/bot_commands/reactions.py | 23 +-- src/core/grades/grade.py | 59 +++---- src/core/grades/grades.py | 46 +++-- src/core/grades/parse_grades.py | 7 +- src/core/predictor.py | 194 +++++++++++---------- src/core/reminder.py | 37 ++-- src/core/schedule/day.py | 24 +-- src/core/schedule/lesson.py | 26 ++- src/core/schedule/parse_schedule.py | 17 +- src/core/schedule/schedule.py | 54 +++--- src/core/subjects/subject.py | 16 +- src/core/subjects/subjects_cache.py | 19 +- src/core/subjects/utils.py | 2 +- src/core/table.py | 7 +- src/feature_manager/feature_initializer.py | 10 +- src/feature_manager/feature_manager.py | 20 ++- src/html2img/html2img.py | 2 +- src/main.py | 19 +- src/message_timers.py | 23 +-- src/utils/first_time_setup.py | 6 +- src/utils/utils.py | 82 ++++----- tests/subjects/__init__.py | 0 tests/subjects/test_subjects_cache.py | 21 +-- tests/subjects/test_utils.py | 6 +- tests/test_grades.py | 8 +- tests/test_reminder.py | 20 ++- tests/test_schedule.py | 36 ++-- 28 files changed, 458 insertions(+), 389 deletions(-) create mode 100644 tests/subjects/__init__.py diff --git a/src/bot_commands/bot_commands.py b/src/bot_commands/bot_commands.py index e61dd3c..1616ae8 100644 --- a/src/bot_commands/bot_commands.py +++ b/src/bot_commands/bot_commands.py @@ -1,13 +1,15 @@ -import core.predictor as predictor import disnake -from constants import CHANNELS, FEATURES -from core.grades.grades import Grades -from core.schedule.schedule import Schedule -from core.subjects.subjects_cache import SubjectsCache from disnake.ext import commands from disnake.ext.commands import InteractionBot -from feature_manager.feature_manager import FeatureManager -from utils.utils import os_environ, write_db +from disnake.interactions import ApplicationCommandInteraction + +from ..constants import CHANNELS, FEATURES +from ..core.grades.grades import Grades +from ..core.predictor import predict_embed +from ..core.schedule.schedule import Schedule +from ..core.subjects.subjects_cache import SubjectsCache +from ..feature_manager.feature_manager import FeatureManager +from ..utils.utils import os_environ, write_db class CustomCog(commands.Cog): @@ -19,13 +21,13 @@ def __init__(self, client: InteractionBot, featureManager: FeatureManager | None class General(CustomCog): async def scheduleCommand( self, - inter: disnake.ApplicationCommandInteraction, + inter: ApplicationCommandInteraction, day_start: int = 1, day_end: int = 5, week: int = 1, show_day: bool | None = None, show_classroom: bool | None = None, - ): + ) -> None: await inter.response.defer() await inter.followup.send( file=await Schedule.db_schedule(bool(week - 1)).render(day_start, day_end, show_day, show_classroom) @@ -69,9 +71,9 @@ async def scheduleCommand( async def gradesPrediction( self, - inter: disnake.ApplicationCommandInteraction, + inter: ApplicationCommandInteraction, subject_name: str, - ): + ) -> None: if not isinstance(inter.channel, disnake.TextChannel): raise Exception("Channel is not a text channel") @@ -80,7 +82,7 @@ async def gradesPrediction( return await inter.response.send_message(f'Předmět "{subject_name}" neexistuje.') await inter.response.send_message("Sending predictor embed message", delete_after=0) - await predictor.predict_embed(subject_name, inter.channel, self.client) + await predict_embed(subject_name, inter.channel, self.client) slashGradePrediction = commands.InvokableSlashCommand( gradesPrediction, @@ -99,9 +101,9 @@ async def gradesPrediction( async def gradesAverage( self, - inter: disnake.ApplicationCommandInteraction, + inter: ApplicationCommandInteraction, subject_name: str, - ): + ) -> None: subject = SubjectsCache.tryGetSubjectByName(subject_name) if subject is None: return await inter.response.send_message(f'Předmět "{subject_name}" neexistuje.') @@ -110,10 +112,10 @@ async def gradesAverage( if average == None: return await inter.response.send_message(f'Pro předmět "{subject_name}" nemáte dosud žádné známky.') - embed = disnake.Embed() + title = str(average) + color = disnake.Color.from_rgb(0, 255, 255) + embed = disnake.Embed(title=title, color=color) embed.set_author(name=f"Průměr z {subject.fullName}:") - embed.title = str(average) - embed.color = disnake.Color.from_rgb(0, 255, 255) await inter.response.send_message(embed=embed) @@ -133,14 +135,14 @@ async def gradesAverage( ) -def admin_user_check(inter: disnake.ApplicationCommandInteraction) -> bool: +def admin_user_check(inter: ApplicationCommandInteraction) -> bool: return inter.author.id == os_environ("adminID") class Admin(CustomCog): group = commands.group(name="admin", description="Admin commands") - async def updateScheduleDatabase(self, inter: disnake.ApplicationCommandInteraction): + async def updateScheduleDatabase(self, inter: ApplicationCommandInteraction) -> None: await inter.response.defer(ephemeral=True) schedule1 = await Schedule.get_schedule(False, self.client) @@ -161,7 +163,7 @@ async def updateScheduleDatabase(self, inter: disnake.ApplicationCommandInteract group=group, ) - async def updateGradesDatabase(self, inter: disnake.ApplicationCommandInteraction): + async def updateGradesDatabase(self, inter: ApplicationCommandInteraction) -> None: await inter.response.defer(ephemeral=True) grades = await Grades.getGrades(self.client) @@ -179,10 +181,10 @@ async def updateGradesDatabase(self, inter: disnake.ApplicationCommandInteractio group=group, ) - async def getSubjects(self, inter: disnake.ApplicationCommandInteraction): + async def getSubjects(self, inter: ApplicationCommandInteraction) -> None: """Gets all cached subjects""" - subjects = [f"{subject.shortName}: {subject.fullName}" for subject in SubjectsCache.subjects] + subjects = [f"{subject.shortOrFullName}: {subject.fullName}" for subject in SubjectsCache.subjects] await inter.send("\n".join(subjects)) slashGetSubjects = commands.InvokableSlashCommand( @@ -203,10 +205,10 @@ class Settings(CustomCog): async def scheduleSettingsCommand( self, - inter: disnake.ApplicationCommandInteraction, + inter: ApplicationCommandInteraction, setting: str, bool: bool, - ): + ) -> None: write_db(self.scheduleSettings[setting], bool) await inter.response.send_message(f"Setting {setting} set to {bool}", ephemeral=True) @@ -235,9 +237,9 @@ async def scheduleSettingsCommand( async def reminderShortSettings( self, - inter: disnake.ApplicationCommandInteraction, + inter: ApplicationCommandInteraction, bool: bool, - ): + ) -> None: write_db("reminderShort", bool) await inter.response.send_message(f"Setting Reminder_short set to {bool}", ephemeral=True) @@ -257,13 +259,13 @@ async def reminderShortSettings( ], ) - async def channel(self, inter: disnake.ApplicationCommandInteraction, function: str): + async def channel(self, inter: ApplicationCommandInteraction, function: str) -> None: write_db(CHANNELS[function], inter.channel_id) await inter.response.send_message(f"channel `{function}` changed to this channel", ephemeral=True) isFunctionAFeature = function in FEATURES if isFunctionAFeature: - await self.featureManager.maybe_start_feature(function, self.client) + await self.featureManager.maybe_start_feature(function, self.client) # type: ignore[arg-type] slashChannel = commands.InvokableSlashCommand( channel, @@ -282,7 +284,7 @@ async def channel(self, inter: disnake.ApplicationCommandInteraction, function: ], ) - async def setup(self, inter: disnake.ApplicationCommandInteraction): + async def setup(self, inter: ApplicationCommandInteraction) -> None: setupMessage = f"Setup the function channels for the bot with the following command in the desired channels:\n" setupMessage += "\n".join([f'"/channel function:{channel}"' for channel in CHANNELS.keys()]) @@ -299,8 +301,7 @@ async def setup(self, inter: disnake.ApplicationCommandInteraction): COGS: list[commands.CogMeta] = [General, Admin, Settings] -def setupBotInteractions(client: commands.InteractionBot, featureManager: FeatureManager): +def setupBotInteractions(client: commands.InteractionBot, featureManager: FeatureManager) -> None: """Sets up the bot's interactions (commands)""" - for cog in COGS: client.add_cog(cog(client, featureManager)) diff --git a/src/bot_commands/reactions.py b/src/bot_commands/reactions.py index 725626c..89320b2 100644 --- a/src/bot_commands/reactions.py +++ b/src/bot_commands/reactions.py @@ -4,11 +4,12 @@ import datetime import disnake -from constants import PREDICTOR_EMOJI -from core.predictor import create_prediction, get_stage, update_grade, update_weight from disnake.ext.commands import InteractionBot -from message_timers import MessageTimer, MessageTimers -from utils.utils import read_db + +from ..constants import PREDICTOR_EMOJI +from ..core.predictor import create_prediction, get_stage, update_grade, update_weight +from ..message_timers import MessageTimer, MessageTimers +from ..utils.utils import read_db class Reactions: @@ -29,7 +30,7 @@ class Predictor: queryMessagesDatabase = "predictorMessages" @classmethod - async def query(cls, client: InteractionBot): + async def query(cls, client: InteractionBot) -> None: # Deletes some removed messages from the database while the bot was off messages = await MessageTimers.query_messages(cls.queryMessagesDatabase, client) if messages: @@ -63,7 +64,7 @@ async def query(cls, client: InteractionBot): # Executes the method for of this function @classmethod - async def execute(cls, reaction: Reactions): + async def execute(cls, reaction: Reactions) -> None: stage = get_stage(reaction.message) if stage == 1: await update_grade(reaction, reaction.client) @@ -74,7 +75,7 @@ class Grades: queryMessagesDatabase = "gradesMessages" @classmethod - async def query(cls, client: InteractionBot): + async def query(cls, client: InteractionBot) -> None: # Deletes some removed messages from the database while the bot was off messages = await MessageTimers.query_messages_reactions(cls.queryMessagesDatabase, client) if messages: @@ -97,14 +98,14 @@ async def query(cls, client: InteractionBot): # Executes the method for of this function @classmethod - async def execute(cls, reaction: Reactions): + async def execute(cls, reaction: Reactions) -> None: if reaction.emoji.name == PREDICTOR_EMOJI: await create_prediction(reaction.message, reaction.client) - REACTIONS = {Predictor, Grades} + REACTIONS: set[type[Predictor] | type[Grades]] = {Predictor, Grades} # Executes the message's command - async def execute(self): + async def execute(self) -> None: for user in self.message.reactions: if user.me: for reaction in Reactions.REACTIONS: @@ -119,7 +120,7 @@ async def execute(self): return @staticmethod - async def query(client: InteractionBot): + async def query(client: InteractionBot) -> None: for reaction in Reactions.REACTIONS: if reaction.queryMessagesDatabase: asyncio.ensure_future(reaction.query(client)) diff --git a/src/core/grades/grade.py b/src/core/grades/grade.py index 5177781..6fd226a 100644 --- a/src/core/grades/grade.py +++ b/src/core/grades/grade.py @@ -4,10 +4,11 @@ from typing import TYPE_CHECKING import disnake -from core.subjects.subjects_cache import SubjectsCache + +from ..subjects.subjects_cache import SubjectsCache if TYPE_CHECKING: - from core.grades.grades import Grades + from ..grades.grades import Grades class Grade: @@ -33,13 +34,14 @@ def __init__( self.gradeValue = gradeValue @staticmethod - def empty_grade(subjectName: str = "", weight: int = 1, gradeValue: float = 1): + def empty_grade(subjectName: str = "", weight: int = 1, gradeValue: float = 1) -> Grade: """Makes a Grade object with as little parameters as possible""" return Grade("", "", subjectName, weight, "", [0, 0, 0], "", gradeValue) - def grade_string(self): + def grade_string(self) -> str: """Returns the `gradeValue` as a string. If the grade is a decimal number, adds a minus to it. - - `gradeValue` can also be non-number, in which case we return the `gradeText`""" + - `gradeValue` can also be non-number, in which case we return the `gradeText` + """ if self.gradeValue is None: return self.gradeText @@ -49,20 +51,13 @@ def grade_string(self): else: return str(int(self.gradeValue)) - def show(self, grades: Grades): + def show(self, grades: Grades) -> disnake.Embed: """ Returns an embed with the grade information - The full subject name is fetched from the subjects cache, if it's not found, fallbacks to the short name """ - # Creation of the embed - embed = disnake.Embed() - - # Subject - embed.set_author(name=self.subjectName) - - # Grade - embed.title = self.grade_string() + title = self.grade_string() # Captions and notes into the same field (Same thing really) captionsNotes = "" @@ -70,13 +65,30 @@ def show(self, grades: Grades): captionsNotes += "\n" + self.caption if self.note: captionsNotes += "\n" + self.note + description = f"Váha: {self.weight}{captionsNotes}" + + # Color of the embed + if self.gradeValue is None: + # If the grade is not a number, the color is simply gray + color = disnake.Color.from_rgb(128, 128, 128) + else: + # Color of the embed from green (good) to red (bad) determining how bad the grade is + green = int(255 / 4 * (self.gradeValue - 1)) + red = int(255 - 255 / 4 * (self.gradeValue - 1)) + color = disnake.Color.from_rgb(green, red, 0) - # Weight - embed.description = f"Váha: {self.weight}{captionsNotes}" + # Date + timestamp = datetime.datetime(self.date[0], self.date[1], self.date[2]) + + # Creation of the embed + embed = disnake.Embed(title=title, description=description, color=color, timestamp=timestamp) + + # Subject + embed.set_author(name=self.subjectName) # Current average maybeSubject = SubjectsCache.tryGetSubjectByName(self.subjectName) - subjectShortName = maybeSubject.shortName if maybeSubject else self.subjectName + subjectShortName = maybeSubject.shortOrFullName if maybeSubject else self.subjectName gradesFromSubject = grades.by_subject_name(self.subjectName) subjectAverage = gradesFromSubject.average() @@ -88,18 +100,5 @@ def show(self, grades: Grades): content = f"Průměr z {subjectShortName}: {subjectAverage}" embed.add_field(name="\u200b", value=content, inline=False) - # Date - embed.timestamp = datetime.datetime(self.date[0], self.date[1], self.date[2]) - - # Color of the embed - if self.gradeValue is None: - # If the grade is not a number, the color is simply gray - embed.color = disnake.Color.from_rgb(128, 128, 128) - else: - # Color of the embed from green (good) to red (bad) determining how bad the grade is - green = int(255 / 4 * (self.gradeValue - 1)) - red = int(255 - 255 / 4 * (self.gradeValue - 1)) - embed.color = disnake.Color.from_rgb(green, red, 0) - # Returns the embed return embed diff --git a/src/core/grades/grades.py b/src/core/grades/grades.py index 2c0f800..b491794 100644 --- a/src/core/grades/grades.py +++ b/src/core/grades/grades.py @@ -5,20 +5,30 @@ import traceback import disnake -from constants import PREDICTOR_EMOJI -from core.grades.grade import Grade -from core.subjects.subject import Subject -from core.subjects.subjects_cache import SubjectsCache from disnake.ext.commands import InteractionBot -from message_timers import MessageTimers -from utils.utils import get_sec, getTextChannel, log_html, login, os_environ, read_db, request, write_db + +from ...constants import PREDICTOR_EMOJI +from ...message_timers import MessageTimers +from ...utils.utils import ( + get_sec, + getTextChannel, + log_html, + login, + os_environ, + read_db, + request, + write_db, +) +from ..subjects.subject import Subject +from ..subjects.subjects_cache import SubjectsCache +from .grade import Grade class Grades: def __init__(self, grades: list[Grade]): self.grades = list(grades) - def by_subject_name(self, subjectName: str): + def by_subject_name(self, subjectName: str) -> Grades: """Returns only Grades with the wanted subject name""" gradesBySubject = filter(lambda grade: grade.subjectName == subjectName, self.grades) @@ -31,7 +41,7 @@ def average(self) -> float | None: # Total amount of grades gradesTotal = 0 # Total amount of grades included with their weights - gradesWeightsTotal = 0 + gradesWeightsTotal: int | float = 0 for grade in self.grades: if grade.gradeValue is None: @@ -46,7 +56,7 @@ def average(self) -> float | None: # Rounds the average and returns it return self.round_average(gradesWeightsTotal / gradesTotal) - def future_average(self, grade: Grade): + def future_average(self, grade: Grade) -> float | None: """Returns the possible future average with the given grade""" # Copies itself to work with a Grades object without damaging the original grades = copy.deepcopy(self) @@ -58,7 +68,7 @@ def future_average(self, grade: Grade): return grades.average() @staticmethod - def round_average(average: float): + def round_average(average: float) -> int | float: """Rounds the average to some normal nice looking finite number""" if average % 1 == 0: return int(average) @@ -77,7 +87,7 @@ def db_grades() -> Grades: return grades - def db_save(self): + def db_save(self) -> None: """Saves the grades to the database""" write_db("grades", self) @@ -110,7 +120,7 @@ async def request_grades(client: InteractionBot) -> str | None: @staticmethod async def getGrades(client: InteractionBot) -> Grades | None: """Requests grades from bakalari server and parses them into a Grades object""" - from core.grades.parse_grades import parseGrades + from ..grades.parse_grades import parseGrades gradesResponse = await Grades.request_grades(client) if gradesResponse is None: @@ -122,7 +132,9 @@ async def getGrades(client: InteractionBot) -> Grades | None: message_remove_timers: list[list[int]] = [] @staticmethod - async def delete_grade_reaction(message: disnake.Message, emoji: disnake.message.EmojiInputType, delay: int): + async def delete_grade_reaction( + message: disnake.Message, emoji: disnake.message.EmojiInputType, delay: int + ) -> None: """Deletes the reaction from the message after some delay""" # Puts the message into the timer variable Grades.message_remove_timers.append([message.id, get_sec() + delay]) @@ -147,7 +159,7 @@ async def delete_grade_reaction(message: disnake.Message, emoji: disnake.message pass @staticmethod - def handle_update_subjects_cache(grades: list[Grade], client: InteractionBot): + def handle_update_subjects_cache(grades: list[Grade], client: InteractionBot) -> None: """Updates the SubjectsCache with the new subjects, if needed""" subjects = [Subject(grade.subjectName, None) for grade in grades] @@ -157,7 +169,7 @@ def handle_update_subjects_cache(grades: list[Grade], client: InteractionBot): SubjectsCache.updateCommandsWithSubjects(client) @staticmethod - async def detect_changes(client: InteractionBot): + async def detect_changes(client: InteractionBot) -> None: """Detects changes in grades and sends them to discord""" # Finds and returns the actual changes @@ -171,7 +183,7 @@ def find_changes(gradesOld: Grades, gradesNew: Grades) -> list[Grade]: return newGrades # Discord message with the information about the changes - async def changed_message(changed: list[Grade], grades: Grades, client: InteractionBot): + async def changed_message(changed: list[Grade], grades: Grades, client: InteractionBot) -> None: channelId: int | None = read_db("channelGrades") if channelId is None: raise Exception("No channelGrades in database") @@ -211,7 +223,7 @@ async def changed_message(changed: list[Grade], grades: Grades, client: Interact gradesNew.db_save() @staticmethod - async def start_detecting_changes(interval: int, client: InteractionBot): + async def start_detecting_changes(interval: int, client: InteractionBot) -> None: """Starts an infinite loop for checking changes in the grades""" while True: try: diff --git a/src/core/grades/parse_grades.py b/src/core/grades/parse_grades.py index 6c7412b..89b51e9 100644 --- a/src/core/grades/parse_grades.py +++ b/src/core/grades/parse_grades.py @@ -2,9 +2,10 @@ import re from bs4 import BeautifulSoup -from core.grades.grade import Grade -from core.grades.grades import Grades -from core.shared_parsers import isBuggedBakalariScript + +from ..shared_parsers import isBuggedBakalariScript +from .grade import Grade +from .grades import Grades def parseGrades(gradesHtml: str) -> Grades | None: diff --git a/src/core/predictor.py b/src/core/predictor.py index 6abbce8..37c7a46 100644 --- a/src/core/predictor.py +++ b/src/core/predictor.py @@ -1,41 +1,34 @@ from typing import TYPE_CHECKING import disnake -from constants import PREDICTOR_EMOJI -from core.grades.grade import Grade -from core.grades.grades import Grades -from core.subjects.subjects_cache import SubjectsCache from disnake.ext.commands import InteractionBot -from message_timers import MessageTimers + +from ..constants import PREDICTOR_EMOJI +from ..message_timers import MessageTimers +from .grades.grade import Grade +from .grades.grades import Grades +from .subjects.subjects_cache import SubjectsCache if TYPE_CHECKING: - from bot_commands.reactions import Reactions + from ..bot_commands.reactions import Reactions GRADES_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"] MINUS_EMOJI = "➖" WEIGHT_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟", "0️⃣", "*️⃣"] -async def predict_embed(subject: str, channel: disnake.TextChannel, client: InteractionBot): +async def predict_embed(subject: str, channel: disnake.TextChannel, client: InteractionBot) -> None: """Creates and sends the predictor embed""" - # Creation of the embed - embed = disnake.Embed() - # Title - embed.title = "Predictor" + color = disnake.Color.from_rgb(102, 0, 255) + title = "Predictor" + embed = disnake.Embed(title=title, color=color) # Subject - embed.add_field( - name=f"Předmět: {subject}", - value="\u200b", - inline=False, - ) + embed.add_field(name=f"Předmět: {subject}", value="\u200b", inline=False) # Grade {select} embed.add_field(name="Známka: *\\{select\\}*", value="\u200b", inline=False) - # Color - embed.color = disnake.Color.from_rgb(102, 0, 255) - # Sends the message message = await channel.send(embed=embed) # Adds reactions @@ -48,98 +41,106 @@ async def predict_embed(subject: str, channel: disnake.TextChannel, client: Inte await MessageTimers.delete_message(message, "predictorMessages", client, 300) -async def update_grade(reaction: "Reactions", client: InteractionBot): +async def update_grade(reaction: "Reactions", client: InteractionBot) -> None: """Edits the embed with the reacted grade""" for loopReaction in reaction.userReactions: + emoji = str(loopReaction.emoji) + # If reacted with the right emoji - if loopReaction.emoji in GRADES_EMOJIS: - # Prevents editing the message multiple times because of async code - if len(reaction.message.embeds[0].fields) == 2: - # Gets the grade - grade = GRADES_EMOJIS.index(loopReaction.emoji) + 1 - if MINUS_EMOJI in [emoji.emoji for emoji in reaction.userReactions]: - if grade != 5: - grade += 0.5 - - # Edits the grade into the embed - editedEmbed = reaction.message.embeds[0] - editedEmbed.set_field_at( - 1, - name=f"Známka: {Grade.empty_grade(gradeValue=grade).grade_string()}", - value="\u200b", - inline=False, - ) - - # Weight {select} - editedEmbed.add_field(name="Váha: *\\{select\\}*", value="\u200b", inline=False) - - # Sends the edited message - await reaction.message.edit(embed=editedEmbed) - - # Removes old reactions and adds new ones - await reaction.message.clear_reactions() - for emoji in WEIGHT_EMOJIS: - await reaction.message.add_reaction(emoji) - - # Removes the message after 5 minutes of inactivity - await MessageTimers.delete_message(reaction.message, "predictorMessages", client, 300) - - -async def update_weight(reaction: "Reactions", client: InteractionBot): + if not emoji in GRADES_EMOJIS: + continue + + # Prevents editing the message multiple times because of async code + if len(reaction.message.embeds[0].fields) == 2: + # Gets the grade + grade: int | float = GRADES_EMOJIS.index(emoji) + 1 + if MINUS_EMOJI in [emoji.emoji for emoji in reaction.userReactions]: + if grade != 5: + grade += 0.5 + + # Edits the grade into the embed + editedEmbed = reaction.message.embeds[0] + editedEmbed.set_field_at( + 1, + name=f"Známka: {Grade.empty_grade(gradeValue=grade).grade_string()}", + value="\u200b", + inline=False, + ) + + # Weight {select} + editedEmbed.add_field(name="Váha: *\\{select\\}*", value="\u200b", inline=False) + + # Sends the edited message + await reaction.message.edit(embed=editedEmbed) + + # Removes old reactions and adds new ones + await reaction.message.clear_reactions() + for emoji in WEIGHT_EMOJIS: + await reaction.message.add_reaction(emoji) + + # Removes the message after 5 minutes of inactivity + await MessageTimers.delete_message(reaction.message, "predictorMessages", client, 300) + + +async def update_weight(reaction: "Reactions", client: InteractionBot) -> None: """Edits the embed with the updated weight and shows the new grade average""" for loopReaction in reaction.userReactions: + emoji = str(loopReaction.emoji) + # If reacted with the right emoji - if loopReaction.emoji in WEIGHT_EMOJIS: - # Prevents editing the message multiple times because of async code - if len(reaction.message.embeds[0].fields) == 3: - # Gets the weight - weight = WEIGHT_EMOJIS.index(loopReaction.emoji) + 1 + if not emoji in WEIGHT_EMOJIS: + continue + + # Prevents editing the message multiple times because of async code + if len(reaction.message.embeds[0].fields) == 3: + # Gets the weight + weight = WEIGHT_EMOJIS.index(emoji) + 1 - # Makes a Grade object from the message - editedEmbed = reaction.message.embeds[0] + # Makes a Grade object from the message + editedEmbed = reaction.message.embeds[0] - # Subject - embedSubject = editedEmbed.fields[0].name - if embedSubject is None: - raise ValueError("Subject not found") + # Subject + embedSubject = editedEmbed.fields[0].name + if embedSubject is None: + raise ValueError("Subject not found") - subjectName = embedSubject.replace("Předmět: ", "") - subject = SubjectsCache.getSubjectByName(subjectName) + subjectName = embedSubject.replace("Předmět: ", "") + subject = SubjectsCache.getSubjectByName(subjectName) - # Grade - embedGrade = editedEmbed.fields[1].name - if embedGrade is None: - raise ValueError("Grade not found") + # Grade + embedGrade = editedEmbed.fields[1].name + if embedGrade is None: + raise ValueError("Grade not found") - grade = Grade.empty_grade( - subjectName=subject.fullName, - weight=weight, - gradeValue=float(embedGrade.replace("Známka: ", "")), - ) + grade = Grade.empty_grade( + subjectName=subject.fullName, + weight=weight, + gradeValue=float(embedGrade.replace("Známka: ", "")), + ) - # Edits the weight into the embed - editedEmbed.set_field_at( - 2, - name=f"Váha: {grade.weight}", - value="\u200b", - inline=False, - ) + # Edits the weight into the embed + editedEmbed.set_field_at( + 2, + name=f"Váha: {grade.weight}", + value="\u200b", + inline=False, + ) - # Average - average = Grades.db_grades().by_subject_name(grade.subjectName).future_average(grade) - editedEmbed.add_field(name=f"Nový průměr: {average}", value="\u200b", inline=False) + # Average + average = Grades.db_grades().by_subject_name(grade.subjectName).future_average(grade) + editedEmbed.add_field(name=f"Nový průměr: {average}", value="\u200b", inline=False) - # Sends the edited message - await reaction.message.edit(embed=editedEmbed) + # Sends the edited message + await reaction.message.edit(embed=editedEmbed) - # Removes the reactions - await reaction.message.clear_reactions() + # Removes the reactions + await reaction.message.clear_reactions() - # Removes the message after 5 minutes of inactivity - await MessageTimers.delete_message(reaction.message, "predictorMessages", client, 300) + # Removes the message after 5 minutes of inactivity + await MessageTimers.delete_message(reaction.message, "predictorMessages", client, 300) -async def create_prediction(message: disnake.Message, client: InteractionBot): +async def create_prediction(message: disnake.Message, client: InteractionBot) -> None: """Generates a predict message with the current subject""" # Subject embed = message.embeds[0].to_dict() @@ -149,8 +150,10 @@ async def create_prediction(message: disnake.Message, client: InteractionBot): raise Exception("No author in prediction embed") subjectFromEmbed = embedAuthor.get("name") + if subjectFromEmbed is None: # pyright: ignore[reportUnnecessaryComparison] + raise Exception("No subject in prediction embed") - subject = SubjectsCache.getSubjectByName(subjectFromEmbed) or subjectFromEmbed + subject = SubjectsCache.getSubjectByName(subjectFromEmbed) # Removes the reaction await MessageTimers.delete_message_reaction(message, "gradesMessages", PREDICTOR_EMOJI, client) @@ -163,7 +166,7 @@ async def create_prediction(message: disnake.Message, client: InteractionBot): await predict_embed(subject.fullName, messageChannel, client) -def get_stage(message: disnake.Message): +def get_stage(message: disnake.Message) -> int: """Gets the stage of the embed and returns as int""" fieldsLen = len(message.embeds[0].fields) if fieldsLen > 1: @@ -172,3 +175,4 @@ def get_stage(message: disnake.Message): return 3 return 2 return 1 + return 0 diff --git a/src/core/reminder.py b/src/core/reminder.py index f841743..d125ec9 100644 --- a/src/core/reminder.py +++ b/src/core/reminder.py @@ -2,11 +2,20 @@ import disnake from attr import dataclass -from core.schedule.day import Day -from core.schedule.lesson import Lesson -from core.schedule.schedule import Schedule from disnake.ext.commands import InteractionBot -from utils.utils import from_sec_to_time, get_sec, get_week_day, getTextChannel, rand_rgb, read_db, write_db + +from ..utils.utils import ( + from_sec_to_time, + get_sec, + get_week_day, + getTextChannel, + rand_rgb, + read_db, + write_db, +) +from .schedule.day import Day +from .schedule.lesson import Lesson +from .schedule.schedule import Schedule REMIND_AFTER_PREVIOUS_CLASS_TIME_SEC = 10 * 60 # 10 minutes """The time after the previous class has started to remind about the next class (in seconds)""" @@ -24,7 +33,7 @@ class RemindTime: remindWholeDaySchedule: bool -async def startReminder(client: InteractionBot): +async def startReminder(client: InteractionBot) -> None: """Starts an infinite loop for sending the lesson reminders""" while True: currentTimeSec = get_sec() @@ -45,7 +54,11 @@ def getNextRemindTime(schedule: Schedule, currentTimeSec: int) -> RemindTime: """ remindWholeDaySchedule = currentTimeSec <= REMIND_WHOLE_DAY_SCHEDULE_TIME if remindWholeDaySchedule: - return RemindTime(REMIND_WHOLE_DAY_SCHEDULE_TIME, lessonTimeIndex=0, remindWholeDaySchedule=True) + return RemindTime( + REMIND_WHOLE_DAY_SCHEDULE_TIME, + lessonTimeIndex=0, + remindWholeDaySchedule=True, + ) for lessonTimeIndex, lessonTime in enumerate(schedule.lessonTimes[:-1], start=1): remindAfterLesson = lessonTime + REMIND_AFTER_PREVIOUS_CLASS_TIME_SEC @@ -54,10 +67,14 @@ def getNextRemindTime(schedule: Schedule, currentTimeSec: int) -> RemindTime: if isLessonTimeSuitable: return RemindTime(remindAfterLesson, lessonTimeIndex, remindWholeDaySchedule=False) - return RemindTime(FULL_DAY_SECS + REMIND_WHOLE_DAY_SCHEDULE_TIME, lessonTimeIndex=0, remindWholeDaySchedule=True) + return RemindTime( + FULL_DAY_SECS + REMIND_WHOLE_DAY_SCHEDULE_TIME, + lessonTimeIndex=0, + remindWholeDaySchedule=True, + ) -async def remind(schedule: Schedule, remindTime: RemindTime, weekDay: int, client: InteractionBot): +async def remind(schedule: Schedule, remindTime: RemindTime, weekDay: int, client: InteractionBot) -> None: """ Reminds the user about the next lesson or also the whole day schedule. - If it's the right time, remind about the whole day schedule + the next lesson @@ -108,7 +125,7 @@ def hasLessonBeenReminded(lesson: Lesson, lastRemindedLesson: Lesson | None) -> ) -async def remindWholeDaySchedule(day: Day, client: InteractionBot): +async def remindWholeDaySchedule(day: Day, client: InteractionBot) -> None: """Sends the whole day schedule""" # Creates the embed with today's schedule embed = disnake.Embed(color=disnake.Color.from_rgb(*rand_rgb())) @@ -127,7 +144,7 @@ async def remindWholeDaySchedule(day: Day, client: InteractionBot): await getTextChannel(channelId, client).send(file=scheduleImg, embed=embed) -async def remindLesson(lesson: Lesson, lessonStartTimeSec: int, client: InteractionBot): +async def remindLesson(lesson: Lesson, lessonStartTimeSec: int, client: InteractionBot) -> None: """Sends a reminder of the lesson to the discord channel""" # Creates the embed with the reminder info diff --git a/src/core/schedule/day.py b/src/core/schedule/day.py index c716be4..b75471b 100644 --- a/src/core/schedule/day.py +++ b/src/core/schedule/day.py @@ -1,7 +1,9 @@ -from constants import DAYS_REVERSED, NUM_OF_LESSONS_IN_DAY -from core.schedule.lesson import Lesson -from core.table import ColumnType, Table -from utils.utils import read_db +import disnake + +from ...constants import DAYS_REVERSED, NUM_OF_LESSONS_IN_DAY +from ...utils.utils import read_db +from ..table import ColumnType, Table +from .lesson import Lesson class Day: @@ -16,7 +18,7 @@ def lessons(self) -> list[Lesson]: return self._lessons @lessons.setter - def lessons(self, lessons: list[Lesson]): + def lessons(self, lessons: list[Lesson]) -> None: # Adds empty lessons if there have been none given if lessons == []: lessons = [Lesson(i) for i in range(NUM_OF_LESSONS_IN_DAY)] @@ -26,7 +28,7 @@ def lessons(self, lessons: list[Lesson]): def empty(self) -> bool: return all([lesson.empty for lesson in self.lessons]) - def change_lesson(self, index: int, lesson: Lesson): + def change_lesson(self, index: int, lesson: Lesson) -> None: """Changes the lesson at the given index to the given lesson. This function is needed for property setter""" self._lessons[index] = lesson @@ -48,14 +50,14 @@ def __eq__(self, other: object) -> bool: return False return True - def render( + async def render( self, shortName: bool = True, showDay: bool | None = None, showClassroom: bool | None = None, renderStyle: Table.Style | None = None, file_name: str = "day.png", - ): + ) -> disnake.File: """Renders the day as an rendered image""" if showClassroom == None: # TODO: Add mongo db and db typing support @@ -73,7 +75,7 @@ def render( dayTableToRender = self.buildDayTable(showClassroom, shortName, showDay) - return Table(dayTableToRender).render(file_name=file_name, style=renderStyle) + return await Table(dayTableToRender).render(file_name=file_name, style=renderStyle) def buildDayTable(self, showClassroom: bool, shortName: bool, showDay: bool) -> ColumnType: """Builds a `Table` object of the day.""" @@ -108,14 +110,14 @@ def getStartEndHours(self) -> tuple[int | None, int | None]: return startHour, endHour # Gets the first non empty lesson of the day. If none then returns None - def first_non_empty_lesson(self): + def first_non_empty_lesson(self) -> Lesson | None: for lesson in self.lessons: if not lesson.empty: return lesson return None # Gets the last non empty lesson of the day. If none then returns None - def last_non_empty_lesson(self): + def last_non_empty_lesson(self) -> Lesson | None: for lesson in reversed(self.lessons): if not lesson.empty: return lesson diff --git a/src/core/schedule/lesson.py b/src/core/schedule/lesson.py index 1046d38..42783d7 100644 --- a/src/core/schedule/lesson.py +++ b/src/core/schedule/lesson.py @@ -1,8 +1,10 @@ from typing import Union -from core.subjects.subject import Subject -from core.table import Table -from utils.utils import read_db +import disnake + +from ...utils.utils import read_db +from ..subjects.subject import Subject +from ..table import Table class Lesson: @@ -36,13 +38,13 @@ def __eq__(self, other: object) -> bool: and self.changeInfo == other.changeInfo ) - def render( + async def render( self, shortName: bool = False, showClassroom: bool | None = None, renderStyle: Table.Style | None = None, file_name: str = "temp.png", - ): + ) -> disnake.File: """Returns a lesson rendered as an image""" if showClassroom == None: showClassroom = read_db("showClassroom") @@ -51,7 +53,7 @@ def render( lessonCell = self.buildLessonTableCell(showClassroom, shortName) - return Table([[lessonCell]]).render(file_name=file_name, style=renderStyle) + return await Table([[lessonCell]]).render(file_name=file_name, style=renderStyle) def buildLessonTableCell( self, @@ -60,10 +62,20 @@ def buildLessonTableCell( ) -> Table.Cell: """Builds a `Table.Cell` object of the lesson.""" - lessonNameText = self.subject and (shortName and self.subject.shortName or self.subject.fullName) + lessonNameText = self.getLessonName(shortName) lessonCell = Table.Cell([Table.Cell.Item(lessonNameText)]) if showClassroom: lessonCell.items.append(Table.Cell.Item(self.classroom)) return lessonCell + + def getLessonName(self, shortName: bool = False) -> str | None: + """ + Returns the lesson name. + - If the lesson is empty, returns None, otherwise returns either the short name or the full name of the subject. + """ + if not self.subject: + return None + + return shortName and self.subject.shortOrFullName or self.subject.fullName diff --git a/src/core/schedule/parse_schedule.py b/src/core/schedule/parse_schedule.py index eb702b0..6745096 100644 --- a/src/core/schedule/parse_schedule.py +++ b/src/core/schedule/parse_schedule.py @@ -2,12 +2,13 @@ import re from bs4 import BeautifulSoup, Tag -from constants import DAYS, SCHOOL_DAYS_IN_WEEK -from core.schedule.day import Day -from core.schedule.lesson import Lesson -from core.schedule.schedule import Schedule -from core.shared_parsers import isBuggedBakalariScript -from core.subjects.subject import Subject + +from ...constants import DAYS, SCHOOL_DAYS_IN_WEEK +from ..shared_parsers import isBuggedBakalariScript +from ..subjects.subject import Subject +from .day import Day +from .lesson import Lesson +from .schedule import Schedule def parseSchedule(scheduleHtml: str, nextWeek: bool) -> Schedule | None: @@ -50,9 +51,9 @@ def parseDay(day: Tag) -> Day: raise ValueError("Couldn't parse day info") weekDay, date = dayInfoGroups - weekDay = DAYS[weekDay] + weekDayIndex = DAYS[weekDay] - return Day(parseLessons(day), weekDay, date) + return Day(parseLessons(day), weekDayIndex, date) def parseLessons(dayEl: Tag) -> list[Lesson]: diff --git a/src/core/schedule/schedule.py b/src/core/schedule/schedule.py index d19f196..3fcadd5 100644 --- a/src/core/schedule/schedule.py +++ b/src/core/schedule/schedule.py @@ -6,14 +6,15 @@ import disnake from attr import dataclass -from constants import NUM_OF_LESSONS_IN_DAY, SCHOOL_DAYS_IN_WEEK -from core.schedule.day import Day -from core.schedule.lesson import Lesson -from core.subjects.subjects_cache import SubjectsCache -from core.table import ColumnType, Table from disnake.ext.commands import InteractionBot -from feature_manager.feature_manager import FeatureManager -from utils.utils import getTextChannel, log_html, login, os_environ, rand_rgb, read_db, request, write_db + +from ...constants import NUM_OF_LESSONS_IN_DAY, SCHOOL_DAYS_IN_WEEK +from ...feature_manager.feature_manager import FeatureManager +from ...utils.utils import getTextChannel, log_html, login, os_environ, rand_rgb, read_db, request, write_db +from ..subjects.subjects_cache import SubjectsCache +from ..table import ColumnType, Table +from .day import Day +from .lesson import Lesson class Schedule: @@ -61,21 +62,21 @@ def last_non_empty_lessons(self) -> int: @staticmethod def db_schedule(nextWeek: bool = False) -> Schedule: """Gets schedule from the database""" - schedule = read_db("schedule1") if not nextWeek else read_db("schedule2") + schedule: Schedule | None = read_db("schedule1") if not nextWeek else read_db("schedule2") if schedule is None: raise Exception("Schedule not found in database") return schedule - def db_save(self): + def db_save(self) -> None: """Saves the schedule to the database""" if not self.nextWeek: write_db("schedule1", self) else: write_db("schedule2", self) - def insert_missing_days(self): + def insert_missing_days(self) -> None: """Inserts missing days into the schedule to make it a full week""" if self.days: start = self.days[0].weekDay @@ -118,7 +119,7 @@ async def request_schedule(nextWeek: bool, client: InteractionBot) -> str | None @staticmethod async def get_schedule(nextWeek: bool, client: InteractionBot) -> Schedule | None: """Returns a Schedule object with the extracted information""" - from core.schedule.parse_schedule import parseSchedule + from .parse_schedule import parseSchedule html = await Schedule.request_schedule(nextWeek, client) @@ -127,7 +128,7 @@ async def get_schedule(nextWeek: bool, client: InteractionBot) -> Schedule | Non return parseSchedule(html, nextWeek) - def render( + async def render( self, dayStart: int, dayEnd: int, @@ -136,7 +137,7 @@ def render( exclusives: list[list[bool]] | None = None, renderStyle: Table.Style | None = None, file_name: str = "table.png", - ): + ) -> disnake.File: """Renders the schedule into an image""" # Uses the setting if inputted else tries looking into the database if showDay == None: @@ -189,9 +190,14 @@ def render( # Adds the actual lessons to the table for day_i, day in enumerate(schedule.days): lessonSubject = day.lessons[i].subject - subjectName = lessonSubject.shortName if lessonSubject else None - - column.append(Table.Cell([Table.Cell.Item(subjectName)], exclusives[day_i][day.lessons[i].hour])) + subjectName = lessonSubject.shortOrFullName if lessonSubject else None + + column.append( + Table.Cell( + [Table.Cell.Item(subjectName)], + exclusives[day_i][day.lessons[i].hour], + ) + ) if showClassroom: column[-1].items.append(Table.Cell.Item(day.lessons[i].classroom)) columns.append(column) @@ -200,7 +206,7 @@ def render( table = Table([[Table.Cell([Table.Cell.Item("Rozvrh je prázdný")])]]) # Returns a rendered table image - return table.render(file_name=file_name, style=renderStyle) + return await table.render(file_name=file_name, style=renderStyle) @dataclass @@ -252,7 +258,7 @@ async def detect_changes(client: InteractionBot) -> bool: return True @staticmethod - def handle_update_subjects_cache(schedules: tuple[Schedule, Schedule], client: InteractionBot): + def handle_update_subjects_cache(schedules: tuple[Schedule, Schedule], client: InteractionBot) -> None: """Updates the subjects cache with the subjects from the schedule""" subjects = [ @@ -268,7 +274,9 @@ def handle_update_subjects_cache(schedules: tuple[Schedule, Schedule], client: I SubjectsCache.updateCommandsWithSubjects(client) @staticmethod - def find_changes(oldNewSchedule: OldNewSchedule) -> list[ChangeDetector.Changed] | None: + def find_changes( + oldNewSchedule: OldNewSchedule, + ) -> list[ChangeDetector.Changed] | None: """Finds any changes in the schedule""" changedList: list[ChangeDetector.Changed] = [] # Iterates over the days @@ -294,7 +302,7 @@ async def changed_message( changed: list[ChangeDetector.Changed], client: InteractionBot, oldNewSchedule: OldNewSchedule, - ): + ) -> None: """Sends the changed schedules over discord""" embedsColor = disnake.Color.from_rgb(*rand_rgb()) # Makes the two embeds containing the changed schedule images @@ -336,7 +344,7 @@ async def changed_message( raise ValueError("Old outdated lesson is None") changedStr += ( - f"**{lessonOld.subject.shortName}{' ' + lessonOld.classroom if lessonOld.classroom else ''}**" + f"**{lessonOld.subject.shortOrFullName}{' ' + lessonOld.classroom if lessonOld.classroom else ''}**" ) changedStr += " -> " if lessonNew.empty: @@ -346,7 +354,7 @@ async def changed_message( raise ValueError("New updated lesson is None") changedStr += ( - f"**{lessonNew.subject.shortName}{' ' + lessonNew.classroom if lessonNew.classroom else ''}**" + f"**{lessonNew.subject.shortOrFullName}{' ' + lessonNew.classroom if lessonNew.classroom else ''}**" ) if lessonNew.changeInfo is not None: changedStr += f"; *{lessonNew.changeInfo}*" @@ -364,7 +372,7 @@ async def changed_message( await channel.send(embed=changedDetail) @staticmethod - async def start_detecting_changes(interval: int, featureManager: FeatureManager, client: InteractionBot): + async def start_detecting_changes(interval: int, featureManager: FeatureManager, client: InteractionBot) -> None: """Starts an infinite loop for checking changes in the schedule""" while True: try: diff --git a/src/core/subjects/subject.py b/src/core/subjects/subject.py index ba888dc..493be05 100644 --- a/src/core/subjects/subject.py +++ b/src/core/subjects/subject.py @@ -15,17 +15,7 @@ def __eq__(self, other: object) -> bool: return self.fullName == other.fullName and self.shortName == other.shortName @property - def shortName(self) -> str: - """Short name of the subject. If not set, fallbacks to the full name""" + def shortOrFullName(self) -> str: + """Returns the short name if set, otherwise the full name""" - return self._shortName or self.fullName - - @shortName.setter - def shortName(self, shortName: str | None): - self._shortName = shortName - - @property - def hasShortName(self) -> bool: - """Returns True if the subject has a short name""" - - return self._shortName is not None + return self.shortName or self.fullName diff --git a/src/core/subjects/subjects_cache.py b/src/core/subjects/subjects_cache.py index f10c92f..4d99926 100644 --- a/src/core/subjects/subjects_cache.py +++ b/src/core/subjects/subjects_cache.py @@ -1,10 +1,11 @@ from __future__ import annotations import disnake -from core.subjects.subject import Subject -from core.subjects.utils import deduplicateSubjects from disnake.ext.commands import InteractionBot -from utils.utils import read_db, write_db + +from ...utils.utils import read_db, write_db +from .subject import Subject +from .utils import deduplicateSubjects class SubjectsCache: @@ -18,7 +19,7 @@ class SubjectsCache: subjects: list[Subject] = [] @classmethod - def initialize(cls): + def initialize(cls) -> None: """Initializes the SubjectsCache""" cls.subjects = cls._dbLoad() @@ -60,11 +61,13 @@ def tryGetSubjectByName(cls, subjectName: str) -> Subject | None: if subject.fullName == subjectName: return subject + return None + @classmethod - def updateCommandsWithSubjects(cls, client: InteractionBot): + def updateCommandsWithSubjects(cls, client: InteractionBot) -> None: """Updates the commands with the subjects""" - from bot_commands.bot_commands import General + from ...bot_commands.bot_commands import General subjectChoices = cls.getSlashCommandSubjectChoices() @@ -121,7 +124,7 @@ def _shouldUpdateSubject(cls, subject: Subject) -> bool: return cachedSubject.shortName != subject.shortName @classmethod - def _updateSubject(cls, subject: Subject): + def _updateSubject(cls, subject: Subject) -> None: """Updates the subject in the cache""" cachedSubject = cls.tryGetSubjectByName(subject.fullName) @@ -142,7 +145,7 @@ def _dbLoad() -> list[Subject]: return subjects @classmethod - def _dbSave(cls): + def _dbSave(cls) -> None: """Saves the Subject objects to the database""" write_db("subjects", cls.subjects) diff --git a/src/core/subjects/utils.py b/src/core/subjects/utils.py index af3f3e5..1a223f8 100644 --- a/src/core/subjects/utils.py +++ b/src/core/subjects/utils.py @@ -1,4 +1,4 @@ -from core.subjects.subject import Subject +from .subject import Subject def deduplicateSubjects(subjects: list[Subject]) -> list[Subject]: diff --git a/src/core/table.py b/src/core/table.py index de79874..017874d 100644 --- a/src/core/table.py +++ b/src/core/table.py @@ -4,8 +4,9 @@ import random import disnake -from constants import TABLE_CSS_PATH -from html2img.html2img import Html2img + +from ..constants import TABLE_CSS_PATH +from ..html2img.html2img import Html2img class Table: @@ -21,7 +22,7 @@ def __init__(self, background: int | None = None, backgroundAngle: int | None = self.set_background_angle() - def set_background_angle(self): + def set_background_angle(self) -> None: self.background = self.background.replace("{ANGLE}", str(self.backgroundAngle)) backgrounds = [ diff --git a/src/feature_manager/feature_initializer.py b/src/feature_manager/feature_initializer.py index 79fd6f7..c2f9b8a 100644 --- a/src/feature_manager/feature_initializer.py +++ b/src/feature_manager/feature_initializer.py @@ -1,8 +1,8 @@ -from core.grades.grades import Grades -from core.reminder import startReminder -from core.schedule.schedule import ChangeDetector -from feature_manager.feature_manager import Feature, FeatureManager -from utils.utils import read_db +from ..core.grades.grades import Grades +from ..core.reminder import startReminder +from ..core.schedule.schedule import ChangeDetector +from ..utils.utils import read_db +from .feature_manager import Feature, FeatureManager def getFeatureManager() -> FeatureManager: diff --git a/src/feature_manager/feature_manager.py b/src/feature_manager/feature_manager.py index ed95bef..c26e10c 100644 --- a/src/feature_manager/feature_manager.py +++ b/src/feature_manager/feature_manager.py @@ -3,28 +3,29 @@ import asyncio from typing import Awaitable, Callable -from constants import FeaturesType from disnake.ext.commands import InteractionBot +from ..constants import FeaturesType + FeatureCallableType = Callable[[InteractionBot], Awaitable[None]] class FeatureManager: """Manages all features of the bot in an event-driven way.""" - def __init__(self): + def __init__(self) -> None: self.features: dict[FeaturesType, Feature] = {} - def register_feature(self, feature: Feature): + def register_feature(self, feature: Feature) -> None: """Registers a feature with its initialization logic.""" self.features[feature.name] = feature - async def initialize(self, client: InteractionBot): + async def initialize(self, client: InteractionBot) -> None: """Initializes all registered features.""" features = (feature.maybeStart(client) for feature in self.features.values()) await asyncio.gather(*features) - async def maybe_start_feature(self, featureName: FeaturesType, client: InteractionBot): + async def maybe_start_feature(self, featureName: FeaturesType, client: InteractionBot) -> None: """Starts a feature by its name if it's not already started.""" feature = self.features.get(featureName) if not feature: @@ -36,7 +37,12 @@ async def maybe_start_feature(self, featureName: FeaturesType, client: Interacti class Feature: """Represents a feature of the bot.""" - def __init__(self, name: FeaturesType, canStart: Callable[..., bool], initializer: FeatureCallableType): + def __init__( + self, + name: FeaturesType, + canStart: Callable[..., bool], + initializer: FeatureCallableType, + ): self.name: FeaturesType = name self.canStart = canStart """A function that returns whether the feature can be started.""" @@ -44,7 +50,7 @@ def __init__(self, name: FeaturesType, canStart: Callable[..., bool], initialize self.isStarted = False - async def maybeStart(self, client: InteractionBot): + async def maybeStart(self, client: InteractionBot) -> None: """Starts the feature if it can be started and it's not already running.""" canStart = self.canStart() and not self.isStarted if canStart: diff --git a/src/html2img/html2img.py b/src/html2img/html2img.py index 83ca9e1..9c25e9b 100644 --- a/src/html2img/html2img.py +++ b/src/html2img/html2img.py @@ -44,7 +44,7 @@ async def render(cls, html: str, css: str) -> BytesIO: browser: Browser | None = None @classmethod - async def getBrowserInstance(cls): + async def getBrowserInstance(cls) -> Browser: """Returns an initialized browser instance""" playwright = await async_playwright().start() browser = await playwright.chromium.launch(headless=True) diff --git a/src/main.py b/src/main.py index 3b7f42a..47cdcad 100644 --- a/src/main.py +++ b/src/main.py @@ -1,13 +1,14 @@ import logging import disnake -from bot_commands.bot_commands import setupBotInteractions -from bot_commands.reactions import Reactions from disnake.ext import commands -from feature_manager.feature_initializer import getFeatureManager -from message_timers import MessageTimers -from utils.first_time_setup import initializeDatabase -from utils.utils import env_load, getTextChannel, os_environ + +from .bot_commands.bot_commands import setupBotInteractions +from .bot_commands.reactions import Reactions +from .feature_manager.feature_initializer import getFeatureManager +from .message_timers import MessageTimers +from .utils.first_time_setup import initializeDatabase +from .utils.utils import env_load, getTextChannel, os_environ logger = logging.getLogger("discord") logger.setLevel(level=logging.INFO) @@ -16,12 +17,12 @@ logger.addHandler(handler) -def main(): +def main() -> None: env_load() client = commands.InteractionBot(intents=disnake.Intents.all()) - async def on_ready(): + async def on_ready() -> None: print("Ready!") initializeDatabase() @@ -33,7 +34,7 @@ async def on_ready(): await featureManager.initialize(client) @client.event - async def on_raw_reaction_add(reaction: disnake.RawReactionActionEvent): + async def on_raw_reaction_add(reaction: disnake.RawReactionActionEvent) -> None: for message in MessageTimers.cached_messages_react: if reaction.message_id == message.id: if reaction.member is None: diff --git a/src/message_timers.py b/src/message_timers.py index 7dcde8a..dc52d56 100644 --- a/src/message_timers.py +++ b/src/message_timers.py @@ -4,7 +4,8 @@ import disnake from disnake.ext.commands import InteractionBot -from utils.utils import get_sec, getTextChannel, read_db, write_db + +from .utils.utils import get_sec, getTextChannel, read_db, write_db # TODO: Reformat this file @@ -61,7 +62,7 @@ async def delete_message( database: str, client: InteractionBot, delay: int = 0, - ): + ) -> None: """Deletes the specified message from the chat after some delay""" linkedMessage = unionizeMessage(message) @@ -89,8 +90,9 @@ async def delete_message( # Checks if the message remove time was changed while sleeping if remove_at == timer.removeAt: try: - message = await getTextChannel(linkedMessage.channelId, client).fetch_message(linkedMessage.id) - await message.delete() + textChannel = getTextChannel(linkedMessage.channelId, client) + fetchedMessage = await textChannel.fetch_message(linkedMessage.id) + await fetchedMessage.delete() except: print( f"""Couldn't get the desired message! Was probably removed!:\n @@ -112,7 +114,7 @@ async def delete_message( return @staticmethod - def stop_message_removal(message: MessageUnionType, database: str): + def stop_message_removal(message: MessageUnionType, database: str) -> None: """Stops the specified message from being removed from the chat""" linkedMessage = unionizeMessage(message) @@ -136,7 +138,7 @@ async def delete_message_reaction( reaction: "EmojiInputType", client: InteractionBot, delay: int = 0, - ): + ) -> None: """Deletes the specified reaction from the message after some delay""" linkedMessage = unionizeMessage(message) @@ -166,7 +168,8 @@ async def delete_message_reaction( # Checks if the message remove time was changed while sleeping if reactionTimer.removeAt == timer.removeAt: try: - message = await getTextChannel(linkedMessage.channelId, client).fetch_message(linkedMessage.id) + textChanel = getTextChannel(linkedMessage.channelId, client) + fetchedMessage = await textChanel.fetch_message(linkedMessage.id) except: print( f"""Couldn't get the desired message! Was probably removed!:\n @@ -174,7 +177,7 @@ async def delete_message_reaction( ) else: try: - await message.clear_reaction(reaction) + await fetchedMessage.clear_reaction(reaction) except: print( f"""Couldn't find the desired reaction! Was probably removed!:\n @@ -261,7 +264,7 @@ async def query_messages_reactions(database: str, client: InteractionBot) -> lis await MessageTimers.message_cache(client, message) foundMessages.append(discordMessage) else: - toRemoveMessages: list[ReactionTimer] | None = read_db(database) + toRemoveMessages = read_db(database) if toRemoveMessages is None: raise Exception(f"Database '{database}' doesn't exist!") @@ -270,7 +273,7 @@ async def query_messages_reactions(database: str, client: InteractionBot) -> lis return foundMessages @staticmethod - async def message_cache(client: InteractionBot, message: MessageUnionType): + async def message_cache(client: InteractionBot, message: MessageUnionType) -> None: """Adds the message into the clients custom cached messages""" if isinstance(message, LinkedMessage): try: diff --git a/src/utils/first_time_setup.py b/src/utils/first_time_setup.py index e1f01bb..d8cacba 100644 --- a/src/utils/first_time_setup.py +++ b/src/utils/first_time_setup.py @@ -1,10 +1,10 @@ import os -from utils.utils import read_db, write_db +from .utils import read_db, write_db # Prints the error to console -def setup_channel_error_message(channel: str): +def setup_channel_error_message(channel: str) -> None: errorMessage = ( f"Setup the bot setting channel{channel} to a specific channel by typing the command: " f'"/channel function:{channel}" in your desired discord channel' @@ -12,7 +12,7 @@ def setup_channel_error_message(channel: str): print(errorMessage) -def initializeDatabase(): +def initializeDatabase() -> bool: """Initializes the database (if the files can even be called that...)""" if not os.path.isdir("./db"): diff --git a/src/utils/utils.py b/src/utils/utils.py index 45ce9e5..f82953e 100644 --- a/src/utils/utils.py +++ b/src/utils/utils.py @@ -12,7 +12,7 @@ # Reads a from the database by key. Returns none if the key doesn't exist -def read_db(key: str): +def read_db(key: str) -> Any | None: try: with open(f"db/{key}.dat", "rb") as f: return pickle.load(f) @@ -21,18 +21,18 @@ def read_db(key: str): # Writes to the database -def write_db(key: str, value: Any): +def write_db(key: str, value: Any) -> None: with open(f"db/{key}.dat", "wb") as f: pickle.dump(value, f, protocol=2) -def env_load(): +def env_load() -> None: """Loads the .env file""" dotenv.load_dotenv(dotenv.find_dotenv()) # Gets os.environ values by key -def os_environ(key: str): +def os_environ(key: str) -> str | int: intKeys = ("adminID",) if key in intKeys: return int(os.environ[key]) @@ -40,7 +40,7 @@ def os_environ(key: str): return os.environ[key] -def getTextChannel(channelId: int, client: InteractionBot): +def getTextChannel(channelId: int, client: InteractionBot) -> disnake.TextChannel: """Gets the text channel by the given id. Throws an error if the channel doesn't exist or is not a text channel""" channel = client.get_channel(channelId) if not isinstance(channel, disnake.TextChannel): @@ -50,16 +50,21 @@ def getTextChannel(channelId: int, client: InteractionBot): # Logs into the server and returns the login session -async def login(client: InteractionBot): +async def login(client: InteractionBot) -> aiohttp.ClientSession | None: username = os_environ("bakalariUsername") password = os_environ("bakalariPassword") bakalariUrl = os_environ("bakalariUrl") url = f"{bakalariUrl}/Login" - data = {"username": username, "password": password, "returnUrl": "", "login": ""} + data: dict[str, str | int] = { + "username": username, + "password": password, + "returnUrl": "", + "login": "", + } session = aiohttp.ClientSession() try: - await session.head(url, timeout=25) + await session.head(url, timeout=aiohttp.ClientTimeout(total=25)) response = await session.post(url, data=data) if response.url.name in ("errinfo.aspx", "Login"): @@ -75,12 +80,14 @@ async def login(client: InteractionBot): return session -async def request(session: aiohttp.ClientSession, url: str, get: bool, client: InteractionBot): +async def request( + session: aiohttp.ClientSession, url: str, get: bool, client: InteractionBot +) -> aiohttp.ClientResponse | None: try: if get: - response = await session.get(url, timeout=25) + response = await session.get(url, timeout=aiohttp.ClientTimeout(total=25)) else: - response = await session.post(url, timeout=25) + response = await session.post(url, timeout=aiohttp.ClientTimeout(total=25)) if response.url.name == "errinfo.aspx": await status(False, client) @@ -94,34 +101,30 @@ async def request(session: aiohttp.ClientSession, url: str, get: bool, client: I return None -async def status(online: bool, client: InteractionBot): +async def status(online: bool, client: InteractionBot) -> None: """Send's the current status of the bakalari server to discord""" - lastStatus = read_db("lastStatus") + lastStatus: tuple[bool, int] | None = read_db("lastStatus") if lastStatus is None: - lastStatus = [online, time_since_epoch_utc()] + lastStatus = (online, time_since_epoch_utc()) write_db("lastStatus", lastStatus) if lastStatus[0] != online: if online == True: - embed = disnake.Embed() - - embed.title = "Server is back online!" + title = "Server is back online!" + color = disnake.Color.from_rgb(0, 255, 0) + embed = disnake.Embed(title=title, color=color) time = for_time(lastStatus[1]) embed.add_field(name="\u200b", value=f"Server was offline for: {time}") - - embed.color = disnake.Color.from_rgb(0, 255, 0) else: - embed = disnake.Embed() - - embed.title = "Server has gone offline!" + title = "Server has gone offline!" + color = disnake.Color.from_rgb(255, 0, 0) + embed = disnake.Embed(color=color) time = for_time(lastStatus[1]) embed.add_field(name="\u200b", value=f"Server was online for: {time}") - embed.color = disnake.Color.from_rgb(255, 0, 0) - channelId = read_db("channelStatus") if channelId is None: raise ValueError("No status channel set") @@ -130,7 +133,7 @@ async def status(online: bool, client: InteractionBot): await getTextChannel(channelId, client).send(embed=embed) -def for_time(time: int): +def for_time(time: int) -> str: forTime = time_since_epoch_utc() - time intervals = ( @@ -143,7 +146,7 @@ def for_time(time: int): ("seconds", 1), ) - def display_time(seconds: int): + def display_time(seconds: int) -> str: result: list[str] = [] for name, count in intervals: value = seconds // count @@ -157,42 +160,42 @@ def display_time(seconds: int): return display_time(forTime) -def time_since_epoch_utc(): - utcTime = pytz.timezone("UTC").localize(datetime.datetime.utcnow()) +def time_since_epoch_utc() -> int: + utcTime = datetime.datetime.now(datetime.timezone.utc) return int(utcTime.timestamp()) -def get_weekday_sec(): +def get_weekday_sec() -> tuple[int, int]: """Returns the current week day and the time in seconds for the Czech republic""" - utcTime = pytz.timezone("UTC").localize(datetime.datetime.utcnow()) + utcTime = datetime.datetime.now(datetime.timezone.utc) czechTime = utcTime.astimezone(pytz.timezone("Europe/Vienna")) sec = czechTime.hour * 3600 + czechTime.minute * 60 + czechTime.second return czechTime.weekday(), sec -def get_sec(): +def get_sec() -> int: """Returns the current time in seconds for the Czech republic""" return get_weekday_sec()[1] -def get_week_day(): +def get_week_day() -> int: """Returns the current week day in the Czech republic""" return get_weekday_sec()[0] # Returns string of inputed time in seconds to {hours:minutes} -def from_sec_to_time(sec: int): +def from_sec_to_time(sec: int) -> str: hours = int(sec / 3600) minutes = int((sec - hours * 3600) / 60) # If minutes is less that 10 then print with 0 in front of minetes {1:01} - if minutes < 10: - minutes = "0" + str(minutes) - output = f"{hours}:{minutes}" + minutesStr = "0" + str(minutes) if minutes < 10 else str(minutes) + + output = f"{hours}:{minutesStr}" return output -async def fetch_message(message_channel: int, message_id: int, client: InteractionBot): +async def fetch_message(message_channel: int, message_id: int, client: InteractionBot) -> disnake.Message | None: try: return await getTextChannel(message_channel, client).fetch_message(message_id) except: @@ -200,13 +203,14 @@ async def fetch_message(message_channel: int, message_id: int, client: Interacti f"""Couldn't get the desired message! Was probably removed!:\n message_id: {message_id}, message_channel: {message_channel}""" ) + return None -def rand_rgb(): +def rand_rgb() -> tuple[int, int, int]: return random.randint(0, 255), random.randint(0, 255), random.randint(0, 255) -def log_html(html: str, filename: str): +def log_html(html: str, filename: str) -> None: """Logs the html into a file""" timeIdentifier = datetime.datetime.now().strftime("%H-%M") with open(f"logs/{filename}-{timeIdentifier}.html", "w", encoding="utf-8") as f: diff --git a/tests/subjects/__init__.py b/tests/subjects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/subjects/test_subjects_cache.py b/tests/subjects/test_subjects_cache.py index fc003d7..eb5384a 100644 --- a/tests/subjects/test_subjects_cache.py +++ b/tests/subjects/test_subjects_cache.py @@ -1,11 +1,12 @@ import disnake import pytest -from core.subjects.subject import Subject -from core.subjects.subjects_cache import SubjectsCache from pytest_mock import MockerFixture +from src.core.subjects.subject import Subject +from src.core.subjects.subjects_cache import SubjectsCache -def test_initialize(mocker: MockerFixture): + +def test_initialize(mocker: MockerFixture) -> None: """Should initialize from the database""" spy = mocker.patch.object(SubjectsCache, "_dbLoad") @@ -13,7 +14,7 @@ def test_initialize(mocker: MockerFixture): spy.assert_called_once() -def test_getSubjectByName(): +def test_getSubjectByName() -> None: """Should return the subject by its name and raise an error if not found""" SubjectsCache.subjects = [Subject("subject1", None)] @@ -23,7 +24,7 @@ def test_getSubjectByName(): pytest.raises(ValueError, SubjectsCache.getSubjectByName, "subject2") -def test_tryGetSubjectByName(): +def test_tryGetSubjectByName() -> None: """Should return the subject by its name and None if not found""" SubjectsCache.subjects = [Subject("subject1", None)] @@ -36,7 +37,7 @@ def test_tryGetSubjectByName(): class Test_HandleUpdateSubjects: - def test_ignore_existing_subjects(self, mocker: MockerFixture): + def test_ignore_existing_subjects(self, mocker: MockerFixture) -> None: """Should ignore already existing subjects""" SubjectsCache.subjects = [Subject("subject1", None)] @@ -48,7 +49,7 @@ def test_ignore_existing_subjects(self, mocker: MockerFixture): assert SubjectsCache.subjects == [Subject("subject1", None)] spy.assert_not_called() - def test_add_new_subjects(self, mocker: MockerFixture): + def test_add_new_subjects(self, mocker: MockerFixture) -> None: """Should add new subjects""" SubjectsCache.subjects = [Subject("subject1", None)] @@ -63,7 +64,7 @@ def test_add_new_subjects(self, mocker: MockerFixture): ] spy.assert_called_once() - def test_update_subjects(self, mocker: MockerFixture): + def test_update_subjects(self, mocker: MockerFixture) -> None: """Should update subjects""" SubjectsCache.subjects = [Subject("subject1", None)] @@ -75,7 +76,7 @@ def test_update_subjects(self, mocker: MockerFixture): assert SubjectsCache.subjects == [Subject("subject1", "short1")] spy.assert_called_once() - def test_multiple_changes(self, mocker: MockerFixture): + def test_multiple_changes(self, mocker: MockerFixture) -> None: """Should handle ignoring, adding and updating deduplicating subjects""" SubjectsCache.subjects = [Subject("subject1", None), Subject("subject2", None)] @@ -100,7 +101,7 @@ def test_multiple_changes(self, mocker: MockerFixture): spy.assert_called_once() -def test_getSlashCommandSubjectChoices(): +def test_getSlashCommandSubjectChoices() -> None: """Should return the subject choices for the slash commands""" SubjectsCache.subjects = [ diff --git a/tests/subjects/test_utils.py b/tests/subjects/test_utils.py index 5b608a0..968dd85 100644 --- a/tests/subjects/test_utils.py +++ b/tests/subjects/test_utils.py @@ -1,8 +1,8 @@ -from core.subjects.subject import Subject -from core.subjects.utils import deduplicateSubjects +from src.core.subjects.subject import Subject +from src.core.subjects.utils import deduplicateSubjects -def test_deduplicateSubjects(): +def test_deduplicateSubjects() -> None: """Should deduplicate subjects""" subjects = [ diff --git a/tests/test_grades.py b/tests/test_grades.py index 973f4d6..c461163 100644 --- a/tests/test_grades.py +++ b/tests/test_grades.py @@ -1,7 +1,7 @@ -from core.grades.grades import Grades -from core.grades.parse_grades import parseGrades +from src.core.grades.grades import Grades +from src.core.grades.parse_grades import parseGrades -from tests.utils import open_html +from .utils import open_html def get_grades(filename: str) -> Grades | None: @@ -10,6 +10,6 @@ def get_grades(filename: str) -> Grades | None: return parseGrades(html) -def test_bugged_grades(): +def test_bugged_grades() -> None: grades = get_grades("grades_bugged_script.html") assert grades is None diff --git a/tests/test_reminder.py b/tests/test_reminder.py index 39062c8..452f8ff 100644 --- a/tests/test_reminder.py +++ b/tests/test_reminder.py @@ -1,15 +1,16 @@ -from core.reminder import ( +from src.core.reminder import ( REMIND_AFTER_PREVIOUS_CLASS_TIME_SEC, REMIND_WHOLE_DAY_SCHEDULE_TIME, getLessonToRemind, getNextRemindTime, ) -from tests.test_schedule import TestSchedules +from .test_schedule import TestSchedules class Test_getNextRemindTime: - def test_remind_whole_day(self): + + def test_remind_whole_day(self) -> None: """Should remind about the whole day schedule with the first lesson""" schedule = TestSchedules.emptySchedule remindTime = getNextRemindTime(schedule, currentTimeSec=50) @@ -18,7 +19,7 @@ def test_remind_whole_day(self): assert remindTime.lessonTimeIndex == 0 assert remindTime.remindWholeDaySchedule is True - def test_remind_about_upcoming_lessons(self): + def test_remind_about_upcoming_lessons(self) -> None: """Should remind about the upcoming lessons""" schedule = TestSchedules.only4thAnd5thLessons firstLessonRemindTime = TestSchedules.defaultLessonTimes[0] + REMIND_AFTER_PREVIOUS_CLASS_TIME_SEC @@ -33,7 +34,7 @@ def test_remind_about_upcoming_lessons(self): assert remindTime2.lessonTimeIndex == 2 assert remindTime2.remindWholeDaySchedule is False - def test_should_go_over_to_next_day(self): + def test_should_go_over_to_next_day(self) -> None: """Should go over to the next day if there are no more lessons""" schedule = TestSchedules.only4thLessons currentTimeSec = TestSchedules.defaultLessonTimes[-1] @@ -45,7 +46,8 @@ def test_should_go_over_to_next_day(self): class Test_getLessonToRemind: - def test_should_get_correct_lesson(self): + + def test_should_get_correct_lesson(self) -> None: """Should get the correct lesson to remind""" schedule = TestSchedules.only4thAnd5thLessons @@ -55,14 +57,14 @@ def test_should_get_correct_lesson(self): lesson2 = getLessonToRemind(schedule.days[0], lessonTimeIndex=4, lastRemindedLesson=None) assert lesson2 == schedule.days[0].lessons[4] - def test_should_skip_empty_lessons(self): + def test_should_skip_empty_lessons(self) -> None: """Should skip over empty lessons""" schedule = TestSchedules.only4thAnd5thLessons lesson = getLessonToRemind(schedule.days[0], lessonTimeIndex=0, lastRemindedLesson=None) assert lesson == schedule.days[0].lessons[3] - def test_should_return_none_for_last_reminded_lesson(self): + def test_should_return_none_for_last_reminded_lesson(self) -> None: """Should return `None` if the last reminded lesson was the last lesson""" schedule = TestSchedules.only4thAnd5thLessons @@ -70,7 +72,7 @@ def test_should_return_none_for_last_reminded_lesson(self): lesson = getLessonToRemind(schedule.days[0], lessonTimeIndex=0, lastRemindedLesson=lastRemindedLesson) assert lesson == None - def test_should_remind_after_the_last_reminded_lesson(self): + def test_should_remind_after_the_last_reminded_lesson(self) -> None: """Should remind after the last reminded lesson, if we are past it""" schedule = TestSchedules.only4thAnd5thLessons diff --git a/tests/test_schedule.py b/tests/test_schedule.py index ed1545d..c420d31 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -1,9 +1,9 @@ -from core.schedule.lesson import Lesson -from core.schedule.parse_schedule import parseSchedule -from core.schedule.schedule import Schedule -from core.subjects.subject import Subject +from src.core.schedule.lesson import Lesson +from src.core.schedule.parse_schedule import parseSchedule +from src.core.schedule.schedule import Schedule +from src.core.subjects.subject import Subject -from tests.utils import open_html +from .utils import open_html class TestSchedules: @@ -55,7 +55,7 @@ def open_schedule(filename: str, nextWeek: bool) -> Schedule | None: return parseSchedule(html, nextWeek) -def print_schedule_differences(schedule1: Schedule, schedule2: Schedule): +def print_schedule_differences(schedule1: Schedule, schedule2: Schedule) -> None: for day1, day2 in zip(schedule1.days, schedule2.days): if day1 != day2: print(f"Day {day1.weekDay} is different:") @@ -72,16 +72,16 @@ def print_schedule_differences(schedule1: Schedule, schedule2: Schedule): bugged_schedule = open_schedule("schedule_bugged_script.html", False) -def test_bugged_schedule(): +def test_bugged_schedule() -> None: assert bugged_schedule == None -def test_empty_schedule(): +def test_empty_schedule() -> None: print_schedule_differences(empty_schedule, TestSchedules.emptySchedule) assert empty_schedule == TestSchedules.emptySchedule -def test_holiday_day_extraction(): +def test_holiday_day_extraction() -> None: """Should extract an empty holiday day correctly""" holidayDay = empty_holiday_day_schedule.days[2] @@ -93,19 +93,19 @@ def test_holiday_day_extraction(): assert holidayDay.lessons[0].subject == None -def test_one_time_lesson_extraction(): +def test_one_time_lesson_extraction() -> None: """Should extract a one time special lesson correctly""" lesson = one_time_lesson_schedule.days[0].lessons[4] assert lesson.hour == 4 assert lesson.empty == False - assert lesson.subject == Subject("Před", None) + assert lesson.subject == Subject("Před", "Před") assert lesson.classroom == None assert lesson.teacher == None assert lesson.changeInfo == None -def test_days_extraction(): +def test_days_extraction() -> None: """Tests if the days are parsed correctly""" days = normal_schedule.days @@ -120,14 +120,14 @@ def test_days_extraction(): assert days[4].weekDay == 4 -def test_lessons_extraction(): +def test_lessons_extraction() -> None: """Should extract lessons correctly""" lessons = normal_schedule.days[0].lessons assert len(lessons) == 13 -def test_regular_lesson(): +def test_regular_lesson() -> None: """Should extract a regular lesson correctly""" lesson = normal_schedule.days[4].lessons[8] @@ -139,7 +139,7 @@ def test_regular_lesson(): assert lesson.changeInfo == None -def test_empty_lessons(): +def test_empty_lessons() -> None: """Should extract an empty lesson correctly""" lesson = normal_schedule.days[0].lessons[0] @@ -151,7 +151,7 @@ def test_empty_lessons(): assert lesson.changeInfo == None -def test_changed_lesson(): +def test_changed_lesson() -> None: """Should extract a changed lesson correctly""" lesson = normal_schedule.days[0].lessons[3] @@ -163,7 +163,7 @@ def test_changed_lesson(): assert lesson.changeInfo == "Zrušeno (Bi, Pecová Barbora)" -def test_removed_lesson(): +def test_removed_lesson() -> None: """Should extract a removed lesson correctly""" lesson = normal_schedule.days[0].lessons[4] @@ -175,7 +175,7 @@ def test_removed_lesson(): assert lesson.changeInfo == "Zrušeno (Zsv, Coufalová Lucie)" -def test_lesson_times(): +def test_lesson_times() -> None: """Should extract lesson times correctly""" lessonTimes = normal_schedule.lessonTimes From 77de541bfbefb13d5be16aed2b9440f88f1c91ec Mon Sep 17 00:00:00 2001 From: Patai5 Date: Sun, 29 Dec 2024 15:06:59 +0100 Subject: [PATCH 3/6] chore: config files --- .editorconfig | 1 + .gitignore | 3 ++- pyproject.toml | 38 ++++++++++++++++++++++++++++++-------- setup.cfg | 25 ------------------------- 4 files changed, 33 insertions(+), 34 deletions(-) create mode 100644 .editorconfig delete mode 100644 setup.cfg diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9cc19d1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1 @@ +max_line_length = 120 diff --git a/.gitignore b/.gitignore index 25ad244..e28871d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ env *.log *.egg-info -__pycache__ \ No newline at end of file +__pycache__ +.history \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6176f0f..05353a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,30 @@ -[build-system] -requires = ["setuptools>=42", "wheel"] -build-backend = "setuptools.build_meta" - -[tool.pytest.ini_options] -pythonpath = [ - "src/bakabot" -] \ No newline at end of file +[project] +name = "bakabot" +description = "Discord bot used for extracting information from Bakalari" +authors = [{ name = "Lukáš Průša" }] +license = { file = "LICENSE" } +requires-python = ">=3.8" +dynamic = ["version"] + +dependencies = [ + "playwright", + "disnake", + "bs4", + "python-dotenv", + "pytz", + "setuptools", +] + +[project.optional-dependencies] +types = ["types-setuptools", "types-pytz", "types-beautifulsoup4"] +test = ["pytest", "pytest-mock"] +format = ["black", "mypy", "autoflake"] + +[tool.black] +line-length = 120 + +[tool.mypy] +strict = true + +[tool.autoflake] +remove-all-unused-imports = true diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 7b86662..0000000 --- a/setup.cfg +++ /dev/null @@ -1,25 +0,0 @@ -[metadata] -name = bakabot -description = Discord bot used for extracting information from Bakalari -author = Lukáš Průša -license_file = LICENSE -platform = win32, linux - -[options] -packege_dir = - =src -packages = find: -install_requires = - playwright - disnake - bs4 - python-dotenv - pytz - pytest - pytest-mock -python_requires = >=3.8 -zip_file = no - -[options.packages.find] -where = src -include = pkg* \ No newline at end of file From 2fef722a3d8bf3d1ce8c979ec95244b6b80bc040 Mon Sep 17 00:00:00 2001 From: Patai5 Date: Sun, 29 Dec 2024 15:07:34 +0100 Subject: [PATCH 4/6] chore: update start script command --- Dockerfile | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 37f6e47..b527c0d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,4 +7,4 @@ COPY src /app/src RUN pip install -e . -CMD ["python3", "src/bakabot/main.py"] \ No newline at end of file +CMD ["python3", "-m", "src.main"] \ No newline at end of file diff --git a/README.md b/README.md index 3745dd3..5d296e1 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ playwright install ``` 7. Start the bot ```sh -python src/BakaBot/main.py +python -m src.main ``` From ea89636b0e034e3333c200f1220a7853b110a5bc Mon Sep 17 00:00:00 2001 From: Patai5 Date: Sun, 29 Dec 2024 15:07:52 +0100 Subject: [PATCH 5/6] ci: add linting and type checking steps --- .github/workflows/ci.yaml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0005164..1b2a23e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,7 +9,7 @@ on: - master jobs: - test: + ci: runs-on: ubuntu-latest steps: @@ -28,3 +28,12 @@ jobs: - name: Run tests run: | pytest + + - name: Run linters + run: | + black --check . + autoflake --check --recursive . + + - name: Run mypy + run: | + mypy . From 6e31a21b739f1813798d01832c7c7baad25fc99f Mon Sep 17 00:00:00 2001 From: Patai5 Date: Sun, 29 Dec 2024 15:33:24 +0100 Subject: [PATCH 6/6] ci: fix installing dependencies --- .github/workflows/ci.yaml | 2 +- README.md | 28 +++++++++++++++------------- pyproject.toml | 16 +++++++++++++--- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1b2a23e..5f183b9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -23,7 +23,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install . + pip install -e .[dev] - name: Run tests run: | diff --git a/README.md b/README.md index 5d296e1..901349a 100644 --- a/README.md +++ b/README.md @@ -41,34 +41,36 @@ And urls in these: 1. Clone the repository 2. Setup your `.env` file from `.env.example` - * Guide on how to get the YOUR DISCORD TOKEN can be found [here](https://www.writebots.com/discord-bot-token/). - * Aditionally you have to turn on all three options under "Privileged Gateway Intents" which can be found under the Bot option on left hand side. - * Guide on how to get the YOUR DISCORD ID can be found [here](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID). -3. Create a virtual environment *(not required, but well recommended)* + - Guide on how to get the YOUR DISCORD TOKEN can be found [here](https://www.writebots.com/discord-bot-token/). + - Aditionally you have to turn on all three options under "Privileged Gateway Intents" which can be found under the Bot option on left hand side. + - Guide on how to get the YOUR DISCORD ID can be found [here](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID). +3. Create a virtual environment _(not required, but well recommended)_ + ```sh python -m venv env ``` + 4. Start the virtual environment + ```sh env\Scripts\activate.bat ``` -5. Install all dependencies + +5. Install dependencies ```sh pip install -e . ``` + _(If you're contributing to the project, install development dependencies:)_ + ```sh + pip install -e .[dev] + ``` 6. Install Playwright + ```sh playwright install ``` + 7. Start the bot ```sh python -m src.main ``` - - - - - - - - diff --git a/pyproject.toml b/pyproject.toml index 05353a1..25bf507 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,9 +16,19 @@ dependencies = [ ] [project.optional-dependencies] -types = ["types-setuptools", "types-pytz", "types-beautifulsoup4"] -test = ["pytest", "pytest-mock"] -format = ["black", "mypy", "autoflake"] +dev = [ + # Types + "mypy", + "types-setuptools", + "types-pytz", + "types-beautifulsoup4", + # Pytest + "pytest", + "pytest-mock", + # Linters + "black", + "autoflake", +] [tool.black] line-length = 120