diff options
Diffstat (limited to 'bot')
79 files changed, 541 insertions, 586 deletions
diff --git a/bot/__init__.py b/bot/__init__.py index 454e235e..9ae64c2e 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -1,6 +1,6 @@ try: from dotenv import load_dotenv - print("Found .env file, loading environment variables from it.") + print("Found .env file, loading environment variables from it.") # noqa: T201 load_dotenv(override=True) except ModuleNotFoundError: pass @@ -1,5 +1,4 @@ import logging -from typing import Optional import discord from discord import DiscordException, Embed @@ -26,7 +25,7 @@ class Bot(BotBase): name = constants.Client.name @property - def member(self) -> Optional[discord.Member]: + def member(self) -> discord.Member | None: """Retrieves the guild member object for the bot.""" guild = self.get_guild(constants.Client.guild) if not guild: diff --git a/bot/constants.py b/bot/constants.py index a5f12fe2..230cccf0 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -129,6 +129,8 @@ Logging = _Logging() class Colours: + """Lookups for commonly used colours.""" + blue = 0x0279FD twitter_blue = 0x1DA1F2 bright_green = 0x01D277 @@ -163,6 +165,8 @@ class Colours: class Emojis: + """Commonly used emojis.""" + cross_mark = "\u274C" check = "\u2611" envelope = "\U0001F4E8" @@ -235,6 +239,8 @@ class Emojis: class Icons: + """URLs to commonly used icons.""" + questionmark = "https://cdn.discordapp.com/emojis/512367613339369475.png" bookmark = ( "https://images-ext-2.discordapp.net/external/zl4oDwcmxUILY7sD9ZWE2fU5R7n6QcxEmPYSE5eddbg/" @@ -243,6 +249,8 @@ class Icons: class Month(enum.IntEnum): + """Month of the year lookup. Used for in_month checks.""" + JANUARY = 1 FEBRUARY = 2 MARCH = 3 diff --git a/bot/exts/avatar_modification/_effects.py b/bot/exts/avatar_modification/_effects.py index f1c2e6d1..fcc62ebb 100644 --- a/bot/exts/avatar_modification/_effects.py +++ b/bot/exts/avatar_modification/_effects.py @@ -1,8 +1,8 @@ import math import random +from collections.abc import Callable from io import BytesIO from pathlib import Path -from typing import Callable, Optional import discord from PIL import Image, ImageDraw, ImageOps @@ -55,7 +55,7 @@ class PfpEffects: @staticmethod def crop_avatar_circle(avatar: Image.Image) -> Image.Image: - """This crops the avatar given into a circle.""" + """Crop the avatar given into a circle.""" mask = Image.new("L", avatar.size, 0) draw = ImageDraw.Draw(mask) draw.ellipse((0, 0) + avatar.size, fill=255) @@ -64,7 +64,7 @@ class PfpEffects: @staticmethod def crop_ring(ring: Image.Image, px: int) -> Image.Image: - """This crops the given ring into a circle.""" + """Crop the given ring into a circle.""" mask = Image.new("L", ring.size, 0) draw = ImageDraw.Draw(mask) draw.ellipse((0, 0) + ring.size, fill=255) @@ -108,7 +108,7 @@ class PfpEffects: return image @staticmethod - def easterify_effect(image: Image.Image, overlay_image: Optional[Image.Image] = None) -> Image.Image: + def easterify_effect(image: Image.Image, overlay_image: Image.Image | None = None) -> Image.Image: """ Applies the easter effect to the given image. diff --git a/bot/exts/avatar_modification/avatar_modify.py b/bot/exts/avatar_modification/avatar_modify.py index 6d1f26f6..4eae269f 100644 --- a/bot/exts/avatar_modification/avatar_modify.py +++ b/bot/exts/avatar_modification/avatar_modify.py @@ -4,9 +4,10 @@ import logging import math import string import unicodedata +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from typing import Callable, Optional, TypeVar, Union +from typing import TypeVar import discord from discord.ext import commands @@ -64,7 +65,7 @@ class AvatarModify(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - async def _fetch_user(self, user_id: int) -> Optional[discord.User]: + async def _fetch_user(self, user_id: int) -> discord.User | None: """ Fetches a user and handles errors. @@ -120,7 +121,7 @@ class AvatarModify(commands.Cog): await ctx.send(embed=embed, file=file) @avatar_modify.command(name="reverse", root_aliases=("reverse",)) - async def reverse(self, ctx: commands.Context, *, text: Optional[str]) -> None: + async def reverse(self, ctx: commands.Context, *, text: str | None) -> None: """ Reverses the sent text. @@ -157,9 +158,9 @@ class AvatarModify(commands.Cog): await ctx.send(embed=embed, file=file) @avatar_modify.command(aliases=("easterify",), root_aliases=("easterify", "avatareasterify")) - async def avatareasterify(self, ctx: commands.Context, *colours: Union[discord.Colour, str]) -> None: + async def avatareasterify(self, ctx: commands.Context, *colours: discord.Colour | str) -> None: """ - This "Easterifies" the user's avatar. + Easterify the user's avatar. Given colours will produce a personalised egg in the corner, similar to the egg_decorate command. If colours are not given, a nice little chocolate bunny will sit in the corner. @@ -168,13 +169,14 @@ class AvatarModify(commands.Cog): """ async def send(*args, **kwargs) -> str: """ - This replaces the original ctx.send. + Replace the original ctx.send. When invoking the egg decorating command, the egg itself doesn't print to to the channel. - Returns the message content so that if any errors occur, the error message can be output. + Return the message content so that if any errors occur, the error message can be output. """ if args: return args[0] + return None async with ctx.typing(): user = await self._fetch_user(ctx.author.id) @@ -248,11 +250,11 @@ class AvatarModify(commands.Cog): ) async def prideavatar(self, ctx: commands.Context, option: str = "lgbt", pixels: int = 64) -> None: """ - This surrounds an avatar with a border of a specified LGBT flag. + Surround an avatar with a border of a specified LGBT flag. - This defaults to the LGBT rainbow flag if none is given. + Default to the LGBT rainbow flag if none is given. The amount of pixels can be given which determines the thickness of the flag border. - This has a maximum of 512px and defaults to a 64px border. + A maximum of 512px is enforced, defaults to a 64px border. The full image is 1024x1024. """ option = option.lower() @@ -272,7 +274,7 @@ class AvatarModify(commands.Cog): @prideavatar.command() async def flags(self, ctx: commands.Context) -> None: - """This lists the flags that can be used with the prideavatar command.""" + """Lists the flags that can be used with the prideavatar command.""" choices = sorted(set(GENDER_OPTIONS.values())) options = "• " + "\n• ".join(choices) embed = discord.Embed( @@ -288,7 +290,7 @@ class AvatarModify(commands.Cog): brief="Spookify a user's avatar." ) async def spookyavatar(self, ctx: commands.Context) -> None: - """This "spookifies" the user's avatar, with a random *spooky* effect.""" + """Spookify the user's avatar, with a random *spooky* effect.""" user = await self._fetch_user(ctx.author.id) if not user: await ctx.send(f"{Emojis.cross_mark} Could not get user info.") diff --git a/bot/exts/core/error_handler.py b/bot/exts/core/error_handler.py index 04ab55d7..fa478d43 100644 --- a/bot/exts/core/error_handler.py +++ b/bot/exts/core/error_handler.py @@ -2,7 +2,6 @@ import logging import math import random from collections.abc import Iterable -from typing import Union from discord import Embed, Message from discord.ext import commands @@ -35,7 +34,7 @@ class CommandErrorHandler(commands.Cog): logging.debug("Cooldown counter reverted as the command was not used correctly.") @staticmethod - def error_embed(message: str, title: Union[Iterable, str] = ERROR_REPLIES) -> Embed: + def error_embed(message: str, title: Iterable | str = ERROR_REPLIES) -> Embed: """Build a basic embed with red colour and either a random error title or a title provided.""" embed = Embed(colour=Colours.soft_red) if isinstance(title, str): @@ -71,7 +70,7 @@ class CommandErrorHandler(commands.Cog): await self.send_command_suggestion(ctx, ctx.invoked_with) return - if isinstance(error, (InChannelCheckFailure, InMonthCheckFailure)): + if isinstance(error, InChannelCheckFailure | InMonthCheckFailure): await ctx.send(embed=self.error_embed(str(error), NEGATIVE_REPLIES), delete_after=7.5) return diff --git a/bot/exts/core/extensions.py b/bot/exts/core/extensions.py index 1d22cf37..87ef2ed1 100644 --- a/bot/exts/core/extensions.py +++ b/bot/exts/core/extensions.py @@ -2,7 +2,6 @@ import functools import logging from collections.abc import Mapping from enum import Enum -from typing import Optional from discord import Colour, Embed from discord.ext import commands @@ -48,7 +47,7 @@ class Extension(commands.Converter): if argument in ctx.bot.all_extensions: return argument - elif (qualified_arg := f"{exts.__name__}.{argument}") in ctx.bot.all_extensions: + if (qualified_arg := f"{exts.__name__}.{argument}") in ctx.bot.all_extensions: return qualified_arg matches = [] @@ -63,10 +62,9 @@ class Extension(commands.Converter): f":x: `{argument}` is an ambiguous extension name. " f"Please use one of the following fully-qualified names.```\n{names}\n```" ) - elif matches: + if matches: return matches[0] - else: - raise commands.BadArgument(f":x: Could not find the extension `{argument}`.") + raise commands.BadArgument(f":x: Could not find the extension `{argument}`.") class Extensions(commands.Cog): @@ -86,7 +84,7 @@ class Extensions(commands.Cog): Load extensions given their fully qualified or unqualified names. If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. - """ # noqa: W605 + """ if not extensions: await self.bot.invoke_help_command(ctx) return @@ -103,7 +101,7 @@ class Extensions(commands.Cog): Unload currently loaded extensions given their fully qualified or unqualified names. If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. - """ # noqa: W605 + """ if not extensions: await self.bot.invoke_help_command(ctx) return @@ -129,7 +127,7 @@ class Extensions(commands.Cog): If '\*' is given as the name, all currently loaded extensions will be reloaded. If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded. - """ # noqa: W605 + """ if not extensions: await self.bot.invoke_help_command(ctx) return @@ -220,7 +218,7 @@ class Extensions(commands.Cog): return msg - async def manage(self, action: Action, ext: str) -> tuple[str, Optional[str]]: + async def manage(self, action: Action, ext: str) -> tuple[str, str | None]: """Apply an action to an extension and return the status message and any error message.""" verb = action.name.lower() error_msg = None diff --git a/bot/exts/core/help.py b/bot/exts/core/help.py index f86eb636..9f96a333 100644 --- a/bot/exts/core/help.py +++ b/bot/exts/core/help.py @@ -3,7 +3,7 @@ import asyncio import itertools import logging from contextlib import suppress -from typing import NamedTuple, Optional, Union +from typing import NamedTuple from discord import Colour, Embed, HTTPException, Message, Reaction, User from discord.ext import commands @@ -38,7 +38,7 @@ class Cog(NamedTuple): log = logging.getLogger(__name__) -class HelpQueryNotFound(ValueError): +class HelpQueryNotFoundError(ValueError): """ Raised when a HelpSession Query doesn't match a command or cog. @@ -49,7 +49,7 @@ class HelpQueryNotFound(ValueError): """ def __init__( - self, arg: str, possible_matches: Optional[list[str]] = None, *, parent_command: Optional[Command] = None + self, arg: str, possible_matches: list[str] | None = None, *, parent_command: Command | None = None ) -> None: super().__init__(arg) self.possible_matches = possible_matches @@ -117,7 +117,7 @@ class HelpSession: self._timeout_task = None self.reset_timeout() - def _get_query(self, query: str) -> Union[Command, Cog]: + def _get_query(self, query: str) -> Command | Cog | None: """Attempts to match the provided query with a valid command or cog.""" command = self._bot.get_command(query) if command: @@ -151,6 +151,7 @@ class HelpSession: ) self._handle_not_found(query) + return None def _handle_not_found(self, query: str) -> None: """ @@ -164,11 +165,11 @@ class HelpSession: parent_command = self._bot.get_command(parent) if parent_command: - raise HelpQueryNotFound('Invalid Subcommand.', parent_command=parent_command) + raise HelpQueryNotFoundError("Invalid Subcommand.", parent_command=parent_command) similar_commands = get_command_suggestions(list(self._bot.all_commands.keys()), query) - raise HelpQueryNotFound(f'Query "{query}" not found.', similar_commands) + raise HelpQueryNotFoundError(f'Query "{query}" not found.', similar_commands) async def timeout(self, seconds: int = 30) -> None: """Waits for a set number of seconds, then stops the help session.""" @@ -178,9 +179,8 @@ class HelpSession: def reset_timeout(self) -> None: """Cancels the original timeout task and sets it again from the start.""" # cancel original if it exists - if self._timeout_task: - if not self._timeout_task.cancelled(): - self._timeout_task.cancel() + if self._timeout_task and not self._timeout_task.cancelled(): + self._timeout_task.cancel() # recreate the timeout task self._timeout_task = self._bot.loop.create_task(self.timeout()) @@ -252,8 +252,7 @@ class HelpSession: pass return f"**{cmd.cog_name}**" - else: - return "**\u200bNo Category:**" + return "**\u200bNo Category:**" def _get_command_params(self, cmd: Command) -> str: """ @@ -304,7 +303,7 @@ class HelpSession: paginator.add_line(f"*{self.description}*") # list all children commands of the queried object - if isinstance(self.query, (commands.GroupMixin, Cog)): + if isinstance(self.query, commands.GroupMixin | Cog): await self._list_child_commands(paginator) self._pages = paginator.pages @@ -417,7 +416,7 @@ class HelpSession: """Returns an Embed with the requested page formatted within.""" embed = Embed() - if isinstance(self.query, (commands.Command, Cog)) and page_number > 0: + if isinstance(self.query, commands.Command | Cog) and page_number > 0: title = f'Command Help | "{self.query.name}"' else: title = self.title @@ -517,7 +516,7 @@ class Help(DiscordCog): """Shows Command Help.""" try: await HelpSession.start(ctx, *commands) - except HelpQueryNotFound as error: + except HelpQueryNotFoundError as error: # Send help message of parent command if subcommand is invalid. if cmd := error.parent_command: diff --git a/bot/exts/core/internal_eval/_helpers.py b/bot/exts/core/internal_eval/_helpers.py index 5b2f8f5d..34ef7fef 100644 --- a/bot/exts/core/internal_eval/_helpers.py +++ b/bot/exts/core/internal_eval/_helpers.py @@ -8,7 +8,7 @@ import logging import sys import traceback import types -from typing import Any, Optional, Union +from typing import Any log = logging.getLogger(__name__) @@ -120,7 +120,7 @@ class EvalContext: log.trace(f"Updating {self._locals} with {locals_}") self._locals.update(locals_) - def prepare_eval(self, code: str) -> Optional[str]: + def prepare_eval(self, code: str) -> str | None: """Prepare an evaluation by processing the code and setting up the context.""" self.code = code @@ -149,7 +149,7 @@ class EvalContext: compiled_code = compile(self.eval_tree, filename=INTERNAL_EVAL_FRAMENAME, mode="exec") log.trace("Executing the compiled code with the desired namespace environment") - exec(compiled_code, self.locals) # noqa: B102,S102 + exec(compiled_code, self.locals) # noqa: S102 log.trace("Awaiting the created evaluation wrapper coroutine.") await self.function() @@ -212,7 +212,7 @@ class CaptureLastExpression(ast.NodeTransformer): self.tree = tree self.last_node = list(ast.iter_child_nodes(tree))[-1] - def visit_Expr(self, node: ast.Expr) -> Union[ast.Expr, ast.Assign]: # noqa: N802 + def visit_Expr(self, node: ast.Expr) -> ast.Expr | ast.Assign: # noqa: N802 """ Replace the Expr node that is last child node of Module with an assignment. @@ -230,7 +230,7 @@ class CaptureLastExpression(ast.NodeTransformer): right_hand_side = list(ast.iter_child_nodes(node))[0] assignment = ast.Assign( - targets=[ast.Name(id='_value_last_expression', ctx=ast.Store())], + targets=[ast.Name(id="_value_last_expression", ctx=ast.Store())], value=right_hand_side, lineno=node.lineno, col_offset=0, diff --git a/bot/exts/core/internal_eval/_internal_eval.py b/bot/exts/core/internal_eval/_internal_eval.py index 2daf8ef9..f5188c1f 100644 --- a/bot/exts/core/internal_eval/_internal_eval.py +++ b/bot/exts/core/internal_eval/_internal_eval.py @@ -1,7 +1,6 @@ import logging import re import textwrap -from typing import Optional import discord from discord.ext import commands @@ -84,7 +83,7 @@ class InternalEval(commands.Cog): return shortened_output - async def _upload_output(self, output: str) -> Optional[str]: + async def _upload_output(self, output: str) -> str | None: """Upload `internal eval` output to our pastebin and return the url.""" data = self.shorten_output(output, max_length=MAX_LENGTH) try: diff --git a/bot/exts/core/source.py b/bot/exts/core/source.py index f771eaca..592156fc 100644 --- a/bot/exts/core/source.py +++ b/bot/exts/core/source.py @@ -1,6 +1,5 @@ import inspect from pathlib import Path -from typing import Optional from discord import Embed from discord.ext import commands @@ -28,7 +27,7 @@ class BotSource(commands.Cog): embed = await self.build_embed(source_item) await ctx.send(embed=embed) - def get_source_link(self, source_item: SourceType) -> tuple[str, str, Optional[int]]: + def get_source_link(self, source_item: SourceType) -> tuple[str, str, int | None]: """ Build GitHub link of source item, return this link, file location and first line number. @@ -62,7 +61,7 @@ class BotSource(commands.Cog): return url, file_location, first_line_no or None - async def build_embed(self, source_object: SourceType) -> Optional[Embed]: + async def build_embed(self, source_object: SourceType) -> Embed | None: """Build embed based on source object.""" url, location, first_line = self.get_source_link(source_object) diff --git a/bot/exts/events/hacktoberfest/hacktober-issue-finder.py b/bot/exts/events/hacktoberfest/hacktober_issue_finder.py index 4f7bef5d..69aa3924 100644 --- a/bot/exts/events/hacktoberfest/hacktober-issue-finder.py +++ b/bot/exts/events/hacktoberfest/hacktober_issue_finder.py @@ -1,7 +1,6 @@ -import datetime import logging import random -from typing import Optional +from datetime import UTC, datetime import discord from discord.ext import commands @@ -28,9 +27,9 @@ class HacktoberIssues(commands.Cog): def __init__(self, bot: Bot): self.bot = bot self.cache_normal = None - self.cache_timer_normal = datetime.datetime(1, 1, 1) + self.cache_timer_normal = datetime(1, 1, 1, tzinfo=UTC) self.cache_beginner = None - self.cache_timer_beginner = datetime.datetime(1, 1, 1) + self.cache_timer_beginner = datetime(1, 1, 1, tzinfo=UTC) @in_month(Month.OCTOBER) @commands.command() @@ -49,7 +48,7 @@ class HacktoberIssues(commands.Cog): embed = self.format_embed(issue) await ctx.send(embed=embed) - async def get_issues(self, ctx: commands.Context, option: str) -> Optional[dict]: + async def get_issues(self, ctx: commands.Context, option: str) -> dict | None: """Get a list of the python issues with the label 'hacktoberfest' from the Github api.""" if option == "beginner": if (ctx.message.created_at.replace(tzinfo=None) - self.cache_timer_beginner).seconds <= 60: diff --git a/bot/exts/events/hacktoberfest/hacktoberstats.py b/bot/exts/events/hacktoberfest/hacktoberstats.py index 5bfac93f..c7fd3601 100644 --- a/bot/exts/events/hacktoberfest/hacktoberstats.py +++ b/bot/exts/events/hacktoberfest/hacktoberstats.py @@ -2,8 +2,7 @@ import logging import random import re from collections import Counter -from datetime import datetime, timedelta -from typing import Optional, Union +from datetime import UTC, datetime, timedelta from urllib.parse import quote_plus import discord @@ -16,7 +15,7 @@ from bot.utils.decorators import in_month log = logging.getLogger(__name__) -CURRENT_YEAR = datetime.now().year # Used to construct GH API query +CURRENT_YEAR = datetime.now(tz=UTC).year # Used to construct GH API query PRS_FOR_SHIRT = 4 # Minimum number of PRs before a shirt is awarded REVIEW_DAYS = 14 # number of days needed after PR can be mature @@ -185,7 +184,7 @@ class HacktoberStats(commands.Cog): logging.info(f"Hacktoberfest PR built for GitHub user '{github_username}'") return stats_embed - async def get_october_prs(self, github_username: str) -> Optional[list[dict]]: + async def get_october_prs(self, github_username: str) -> list[dict] | None: """ Query GitHub's API for PRs created during the month of October by github_username. @@ -234,9 +233,8 @@ class HacktoberStats(commands.Cog): # Ignore logging non-existent users or users we do not have permission to see if api_message == GITHUB_NONEXISTENT_USER_MESSAGE: log.debug(f"No GitHub user found named '{github_username}'") - return - else: - log.error(f"GitHub API request for '{github_username}' failed with message: {api_message}") + return None + log.error(f"GitHub API request for '{github_username}' failed with message: {api_message}") return [] # No October PRs were found due to error if jsonresp["total_count"] == 0: @@ -246,7 +244,7 @@ class HacktoberStats(commands.Cog): logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'") outlist = [] # list of pr information dicts that will get returned - oct3 = datetime(int(CURRENT_YEAR), 10, 3, 23, 59, 59, tzinfo=None) + oct3 = datetime(int(CURRENT_YEAR), 10, 3, 23, 59, 59, tzinfo=UTC) hackto_topics = {} # cache whether each repo has the appropriate topic (bool values) for item in jsonresp["items"]: shortname = self._get_shortname(item["repository_url"]) @@ -255,15 +253,14 @@ class HacktoberStats(commands.Cog): "repo_shortname": shortname, "created_at": datetime.strptime( item["created_at"], "%Y-%m-%dT%H:%M:%SZ" - ), + ).replace(tzinfo=UTC), "number": item["number"] } # If the PR has 'invalid' or 'spam' labels, the PR must be # either merged or approved for it to be included - if self._has_label(item, ["invalid", "spam"]): - if not await self._is_accepted(itemdict): - continue + if self._has_label(item, ["invalid", "spam"]) and not await self._is_accepted(itemdict): + continue # PRs before oct 3 no need to check for topics # continue the loop if 'hacktoberfest-accepted' is labelled then @@ -302,7 +299,7 @@ class HacktoberStats(commands.Cog): return await resp.json() @staticmethod - def _has_label(pr: dict, labels: Union[list[str], str]) -> bool: + def _has_label(pr: dict, labels: list[str] | str) -> bool: """ Check if a PR has label 'labels'. @@ -313,7 +310,7 @@ class HacktoberStats(commands.Cog): return False if isinstance(labels, str) and any(label["name"].casefold() == labels for label in pr["labels"]): return True - for item in labels: + for item in labels: # noqa: SIM110 if any(label["name"].casefold() == item for label in pr["labels"]): return True return False @@ -350,10 +347,7 @@ class HacktoberStats(commands.Cog): return False # loop through reviews and check for approval - for item in jsonresp2: - if item.get("status") == "APPROVED": - return True - return False + return any(item.get("status") == "APPROVED" for item in jsonresp2) @staticmethod def _get_shortname(in_url: str) -> str: @@ -378,8 +372,8 @@ class HacktoberStats(commands.Cog): PRs that are accepted must either be merged, approved, or labelled 'hacktoberfest-accepted. """ - now = datetime.now() - oct3 = datetime(CURRENT_YEAR, 10, 3, 23, 59, 59, tzinfo=None) + now = datetime.now(tz=UTC) + oct3 = datetime(CURRENT_YEAR, 10, 3, 23, 59, 59, tzinfo=UTC) in_review = [] accepted = [] for pr in prs: @@ -420,8 +414,7 @@ class HacktoberStats(commands.Cog): """Return "contribution" or "contributions" based on the value of n.""" if n == 1: return "contribution" - else: - return "contributions" + return "contributions" @staticmethod def _author_mention_from_context(ctx: commands.Context) -> tuple[str, str]: diff --git a/bot/exts/events/hacktoberfest/timeleft.py b/bot/exts/events/hacktoberfest/timeleft.py index f470e932..8f46d798 100644 --- a/bot/exts/events/hacktoberfest/timeleft.py +++ b/bot/exts/events/hacktoberfest/timeleft.py @@ -1,5 +1,5 @@ import logging -from datetime import datetime +from datetime import UTC, datetime from discord.ext import commands @@ -9,25 +9,25 @@ log = logging.getLogger(__name__) class TimeLeft(commands.Cog): - """A Cog that tells you how long left until Hacktober is over!""" + """A Cog that tells users how long left until Hacktober is over!""" def in_hacktober(self) -> bool: """Return True if the current time is within Hacktoberfest.""" _, end, start = self.load_date() - now = datetime.utcnow() + now = datetime.now(tz=UTC) return start <= now <= end @staticmethod def load_date() -> tuple[datetime, datetime, datetime]: - """Return of a tuple of the current time and the end and start times of the next October.""" - now = datetime.utcnow() + """Return of a tuple of the current time and the end and start times of the next Hacktober.""" + now = datetime.now(tz=UTC) year = now.year if now.month > 10: year += 1 - end = datetime(year, 11, 1, 12) # November 1st 12:00 (UTC-12:00) - start = datetime(year, 9, 30, 10) # September 30th 10:00 (UTC+14:00) + end = datetime(year, 11, 1, 12, tzinfo=UTC) # November 1st 12:00 (UTC-12:00) + start = datetime(year, 9, 30, 10, tzinfo=UTC) # September 30th 10:00 (UTC+14:00) return now, end, start @commands.command() diff --git a/bot/exts/events/trivianight/_game.py b/bot/exts/events/trivianight/_game.py index 8b012a17..15126f60 100644 --- a/bot/exts/events/trivianight/_game.py +++ b/bot/exts/events/trivianight/_game.py @@ -1,7 +1,8 @@ import time +from collections.abc import Iterable from random import randrange from string import ascii_uppercase -from typing import Iterable, NamedTuple, Optional, TypedDict +from typing import NamedTuple, TypedDict DEFAULT_QUESTION_POINTS = 10 DEFAULT_QUESTION_TIME = 20 @@ -14,8 +15,8 @@ class QuestionData(TypedDict): description: str answers: list[str] correct: str - points: Optional[int] - time: Optional[int] + points: int | None + time: int | None class UserGuess(NamedTuple): @@ -26,15 +27,15 @@ class UserGuess(NamedTuple): elapsed: float -class QuestionClosed(RuntimeError): +class QuestionClosedError(RuntimeError): """Exception raised when the question is not open for guesses anymore.""" -class AlreadyUpdated(RuntimeError): +class AlreadyUpdatedError(RuntimeError): """Exception raised when the user has already updated their guess once.""" -class AllQuestionsVisited(RuntimeError): +class AllQuestionsVisitedError(RuntimeError): """Exception raised when all of the questions have been visited.""" @@ -90,10 +91,10 @@ class Question: def _update_guess(self, user: int, answer: str) -> UserGuess: """Update an already existing guess.""" if self._started is None: - raise QuestionClosed("Question is not open for answers.") + raise QuestionClosedError("Question is not open for answers.") if self._guesses[user][1] is False: - raise AlreadyUpdated(f"User({user}) has already updated their guess once.") + raise AlreadyUpdatedError(f"User({user}) has already updated their guess once.") self._guesses[user] = (answer, False, time.perf_counter() - self._started) return self._guesses[user] @@ -104,7 +105,7 @@ class Question: return self._update_guess(user, answer) if self._started is None: - raise QuestionClosed("Question is not open for answers.") + raise QuestionClosedError("Question is not open for answers.") self._guesses[user] = (answer, True, time.perf_counter() - self._started) return self._guesses[user] @@ -126,7 +127,7 @@ class TriviaNightGame: self._questions = [Question(q) for q in data] # A copy of the questions to keep for `.trivianight list` self._all_questions = list(self._questions) - self.current_question: Optional[Question] = None + self.current_question: Question | None = None self._points = {} self._speed = {} @@ -148,7 +149,7 @@ class TriviaNightGame: except IndexError: raise ValueError(f"Question number {number} does not exist.") elif len(self._questions) == 0: - raise AllQuestionsVisited("All of the questions have been visited.") + raise AllQuestionsVisitedError("All of the questions have been visited.") else: question = self._questions.pop(randrange(len(self._questions))) diff --git a/bot/exts/events/trivianight/_questions.py b/bot/exts/events/trivianight/_questions.py index 5f1046dc..a0dd545e 100644 --- a/bot/exts/events/trivianight/_questions.py +++ b/bot/exts/events/trivianight/_questions.py @@ -7,7 +7,7 @@ from discord.ui import Button, View from bot.constants import Colours, NEGATIVE_REPLIES -from ._game import AlreadyUpdated, Question, QuestionClosed +from ._game import AlreadyUpdatedError, Question, QuestionClosedError from ._scoreboard import Scoreboard @@ -29,7 +29,7 @@ class AnswerButton(Button): """ try: guess = self.question.guess(interaction.user.id, self.label) - except AlreadyUpdated: + except AlreadyUpdatedError: await interaction.response.send_message( embed=Embed( title=choice(NEGATIVE_REPLIES), @@ -39,7 +39,7 @@ class AnswerButton(Button): ephemeral=True ) return - except QuestionClosed: + except QuestionClosedError: await interaction.response.send_message( embed=Embed( title=choice(NEGATIVE_REPLIES), @@ -91,7 +91,7 @@ class QuestionView(View): - text: A string that represents the question description to 'unicodeify' """ return "".join( - f"{letter}\u200b" if letter not in ('\n', '\t', '`', 'p', 'y') else letter + f"{letter}\u200b" if letter not in ("\n", "\t", "`", "p", "y") else letter for idx, letter in enumerate(text) ) @@ -127,13 +127,13 @@ class QuestionView(View): if len(guesses) != 0: answers_chosen = { answer_choice: len( - tuple(filter(lambda x: x[0] == answer_choice, guesses.values())) # noqa: B023 + tuple(filter(lambda x: x[0] == answer_choice, guesses.values())) ) for answer_choice in labels } answers_chosen = dict( - sorted(list(answers_chosen.items()), key=lambda item: item[1], reverse=True) + sorted(answers_chosen.items(), key=lambda item: item[1], reverse=True) ) for answer, people_answered in answers_chosen.items(): diff --git a/bot/exts/events/trivianight/trivianight.py b/bot/exts/events/trivianight/trivianight.py index 10435551..f90d32e0 100644 --- a/bot/exts/events/trivianight/trivianight.py +++ b/bot/exts/events/trivianight/trivianight.py @@ -1,7 +1,6 @@ import asyncio from json import JSONDecodeError, loads from random import choice -from typing import Optional from discord import Embed from discord.ext import commands @@ -10,7 +9,7 @@ from bot.bot import Bot from bot.constants import Colours, NEGATIVE_REPLIES, POSITIVE_REPLIES, Roles from bot.utils.pagination import LinePaginator -from ._game import AllQuestionsVisited, TriviaNightGame +from ._game import AllQuestionsVisitedError, TriviaNightGame from ._questions import QuestionView from ._scoreboard import Scoreboard @@ -23,8 +22,8 @@ class TriviaNightCog(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - self.game: Optional[TriviaNightGame] = None - self.scoreboard: Optional[Scoreboard] = None + self.game: TriviaNightGame | None = None + self.scoreboard: Scoreboard | None = None self.question_closed: asyncio.Event = None @commands.group(aliases=["tn"], invoke_without_command=True) @@ -46,7 +45,7 @@ class TriviaNightCog(commands.Cog): @trivianight.command() @commands.has_any_role(*TRIVIA_NIGHT_ROLES) - async def load(self, ctx: commands.Context, *, to_load: Optional[str]) -> None: + async def load(self, ctx: commands.Context, *, to_load: str | None) -> None: """ Loads a JSON file from the provided attachment or argument. @@ -76,8 +75,7 @@ class TriviaNightCog(commands.Cog): elif not to_load: raise commands.BadArgument("You didn't attach an attachment nor link a message!") elif ( - to_load.startswith("https://discord.com/channels") - or to_load.startswith("https://discordapp.com/channels") + to_load.startswith(("https://discord.com/channels", "https://discordapp.com/channels")) ): channel_id, message_id = to_load.split("/")[-2:] channel = await ctx.guild.fetch_channel(int(channel_id)) @@ -107,7 +105,7 @@ class TriviaNightCog(commands.Cog): await ctx.send(embed=success_embed) - @trivianight.command(aliases=('next',)) + @trivianight.command(aliases=("next",)) @commands.has_any_role(*TRIVIA_NIGHT_ROLES) async def question(self, ctx: commands.Context, question_number: str = None) -> None: """ @@ -135,7 +133,7 @@ class TriviaNightCog(commands.Cog): try: next_question = self.game.next_question(question_number) - except AllQuestionsVisited: + except AllQuestionsVisitedError: error_embed = Embed( title=choice(NEGATIVE_REPLIES), description="All of the questions have been used.", diff --git a/bot/exts/fun/anagram.py b/bot/exts/fun/anagram.py index d8ea6a55..8210d1d5 100644 --- a/bot/exts/fun/anagram.py +++ b/bot/exts/fun/anagram.py @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) TIME_LIMIT = 60 # anagram.json file contains all the anagrams -with open(Path("bot/resources/fun/anagram.json"), "r") as f: +with open(Path("bot/resources/fun/anagram.json")) as f: ANAGRAMS_ALL = json.load(f) diff --git a/bot/exts/fun/battleship.py b/bot/exts/fun/battleship.py index a8039cf2..4a552605 100644 --- a/bot/exts/fun/battleship.py +++ b/bot/exts/fun/battleship.py @@ -4,7 +4,6 @@ import random import re from dataclasses import dataclass from functools import partial -from typing import Optional import discord from discord.ext import commands @@ -19,7 +18,7 @@ log = logging.getLogger(__name__) class Square: """Each square on the battleship grid - if they contain a boat and if they've been aimed at.""" - boat: Optional[str] + boat: str | None aimed: bool @@ -31,8 +30,8 @@ EmojiSet = dict[tuple[bool, bool], str] class Player: """Each player in the game - their messages for the boards and their current grid.""" - user: Optional[discord.Member] - board: Optional[discord.Message] + user: discord.Member | None + board: discord.Message | None opponent_board: discord.Message grid: Grid @@ -110,10 +109,10 @@ class Game: self.gameover: bool = False - self.turn: Optional[Player] = None - self.next: Optional[Player] = None + self.turn: Player | None = None + self.next: Player | None = None - self.match: Optional[re.Match] = None + self.match: re.Match | None = None self.surrender: bool = False self.setup_grids() @@ -135,7 +134,7 @@ class Game: for row in player.grid ] - rows = ["".join([number] + row) for number, row in zip(NUMBERS, grid)] + rows = ["".join([number] + row) for number, row in zip(NUMBERS, grid, strict=True)] return "\n".join([LETTERS] + rows) @staticmethod @@ -215,7 +214,7 @@ class Game: (self.p1, "board"), (self.p2, "board") ) - for board, location in zip(boards, locations): + for board, location in zip(boards, locations, strict=True): player, attr = location if getattr(player, attr): await getattr(player, attr).edit(content=board) @@ -232,8 +231,9 @@ class Game: if not self.match: self.bot.loop.create_task(message.add_reaction(CROSS_EMOJI)) return bool(self.match) + return None - async def take_turn(self) -> Optional[Square]: + async def take_turn(self) -> Square | None: """Lets the player who's turn it is choose a square.""" square = None turn_message = await self.turn.user.send( diff --git a/bot/exts/fun/catify.py b/bot/exts/fun/catify.py index 6e8c75ba..67d17292 100644 --- a/bot/exts/fun/catify.py +++ b/bot/exts/fun/catify.py @@ -1,6 +1,5 @@ import random from contextlib import suppress -from typing import Optional from discord import AllowedMentions, Embed, Forbidden from discord.ext import commands @@ -15,7 +14,7 @@ class Catify(commands.Cog): @commands.command(aliases=("ᓚᘏᗢify", "ᓚᘏᗢ")) @commands.cooldown(1, 5, commands.BucketType.user) - async def catify(self, ctx: commands.Context, *, text: Optional[str]) -> None: + async def catify(self, ctx: commands.Context, *, text: str | None) -> None: """ Convert the provided text into a cat themed sentence by interspercing cats throughout text. @@ -36,13 +35,12 @@ class Catify(commands.Cog): await ctx.send(embed=embed) return - else: - display_name += f" | {random.choice(Cats.cats)}" + display_name += f" | {random.choice(Cats.cats)}" - await ctx.send(f"Your catified nickname is: `{display_name}`", allowed_mentions=AllowedMentions.none()) + await ctx.send(f"Your catified nickname is: `{display_name}`", allowed_mentions=AllowedMentions.none()) - with suppress(Forbidden): - await ctx.author.edit(nick=display_name) + with suppress(Forbidden): + await ctx.author.edit(nick=display_name) else: if len(text) >= 1500: embed = Embed( @@ -61,9 +59,9 @@ class Catify(commands.Cog): string_list[index] = name.replace("cat", f"**{random.choice(Cats.cats)}**") else: string_list[index] = name.replace("cat", random.choice(Cats.cats)) - for element in Cats.cats: - if element in name: - string_list[index] = name.replace(element, "cat") + for cat in Cats.cats: + if cat in name: + string_list[index] = name.replace(cat, "cat") string_len = len(string_list) // 3 or len(string_list) diff --git a/bot/exts/fun/connect_four.py b/bot/exts/fun/connect_four.py index 0d870a6e..6544dc48 100644 --- a/bot/exts/fun/connect_four.py +++ b/bot/exts/fun/connect_four.py @@ -1,7 +1,6 @@ import asyncio import random from functools import partial -from typing import Optional, Union import discord import emojis @@ -14,8 +13,8 @@ from bot.constants import Emojis NUMBERS = list(Emojis.number_emojis.values()) CROSS_EMOJI = Emojis.incident_unactioned -Coordinate = Optional[tuple[int, int]] -EMOJI_CHECK = Union[discord.Emoji, str] +Coordinate = tuple[int, int] | None +EMOJI_CHECK = discord.Emoji | str class Game: @@ -26,7 +25,7 @@ class Game: bot: Bot, channel: discord.TextChannel, player1: discord.Member, - player2: Optional[discord.Member], + player2: discord.Member | None, tokens: list[str], size: int = 7 ): @@ -73,7 +72,7 @@ class Game: await self.message.edit(content=None, embed=embed) async def game_over( - self, action: str, player1: Union[ClientUser, Member], player2: Union[ClientUser, Member] + self, action: str, player1: ClientUser | Member, player2: ClientUser | Member ) -> None: """Announces to public chat.""" if action == "win": @@ -134,12 +133,12 @@ class Game: reaction, user = await self.bot.wait_for("reaction_add", check=self.predicate, timeout=30.0) except asyncio.TimeoutError: await self.channel.send(f"{self.player_active.mention}, you took too long. Game over!") - return + return None else: await message.delete() if str(reaction.emoji) == CROSS_EMOJI: await self.game_over("quit", self.player_active, self.player_inactive) - return + return None await self.message.remove_reaction(reaction, user) @@ -197,7 +196,7 @@ class AI: break return possible_coords - def check_ai_win(self, coord_list: list[Coordinate]) -> Optional[Coordinate]: + def check_ai_win(self, coord_list: list[Coordinate]) -> Coordinate: """ Check AI win. @@ -205,12 +204,13 @@ class AI: with 10% chance of not winning and returning None """ if random.randint(1, 10) == 1: - return + return None for coords in coord_list: if self.game.check_win(coords, 2): return coords + return None - def check_player_win(self, coord_list: list[Coordinate]) -> Optional[Coordinate]: + def check_player_win(self, coord_list: list[Coordinate]) -> Coordinate | None: """ Check Player win. @@ -218,17 +218,18 @@ class AI: from winning with 25% of not blocking them and returning None. """ if random.randint(1, 4) == 1: - return + return None for coords in coord_list: if self.game.check_win(coords, 1): return coords + return None @staticmethod def random_coords(coord_list: list[Coordinate]) -> Coordinate: """Picks a random coordinate from the possible ones.""" return random.choice(coord_list) - def play(self) -> Union[Coordinate, bool]: + def play(self) -> Coordinate | bool: """ Plays for the AI. @@ -331,7 +332,7 @@ class ConnectFour(commands.Cog): @staticmethod def check_emojis( e1: EMOJI_CHECK, e2: EMOJI_CHECK - ) -> tuple[bool, Optional[str]]: + ) -> tuple[bool, str | None]: """Validate the emojis, the user put.""" if isinstance(e1, str) and emojis.count(e1) != 1: return False, e1 @@ -342,7 +343,7 @@ class ConnectFour(commands.Cog): async def _play_game( self, ctx: commands.Context, - user: Optional[discord.Member], + user: discord.Member | None, board_size: int, emoji1: str, emoji2: str diff --git a/bot/exts/fun/duck_game.py b/bot/exts/fun/duck_game.py index a2612e51..3f34ece7 100644 --- a/bot/exts/fun/duck_game.py +++ b/bot/exts/fun/duck_game.py @@ -42,7 +42,7 @@ CARD_HEIGHT = 97 EMOJI_WRONG = "\u274C" -ANSWER_REGEX = re.compile(r'^\D*(\d+)\D+(\d+)\D+(\d+)\D*$') +ANSWER_REGEX = re.compile(r"^\D*(\d+)\D+(\d+)\D+(\d+)\D*$") HELP_TEXT = """ **Each card has 4 features** @@ -97,7 +97,7 @@ def get_card_image(card: tuple[int]) -> Image: def as_trinary(card: tuple[int]) -> int: """Find the card's unique index by interpreting its features as trinary.""" - return int(''.join(str(x) for x in card), base=3) + return int("".join(str(x) for x in card), base=3) class DuckGame: @@ -156,7 +156,7 @@ class DuckGame: # which is prevented by the triangle iteration. completion = tuple( feat_a if feat_a == feat_b else 3-feat_a-feat_b - for feat_a, feat_b in zip(card_a, card_b) + for feat_a, feat_b in zip(card_a, card_b, strict=True) ) try: idx_c = self.board.index(completion) @@ -178,8 +178,8 @@ class DuckGamesDirector(commands.Cog): self.current_games = {} @commands.group( - name='duckduckduckgoose', - aliases=['dddg', 'ddg', 'duckduckgoose', 'duckgoose'], + name="duckduckduckgoose", + aliases=["dddg", "ddg", "duckduckgoose", "duckgoose"], invoke_without_command=True ) @commands.cooldown(rate=1, per=2, type=commands.BucketType.channel) @@ -218,7 +218,7 @@ class DuckGamesDirector(commands.Cog): return game = self.current_games[channel.id] - if msg.content.strip().lower() == 'goose': + if msg.content.strip().lower() == "goose": # If all of the solutions have been claimed, i.e. the "goose" call is correct. if len(game.solutions) == len(game.claimed_answers): try: diff --git a/bot/exts/fun/game.py b/bot/exts/fun/game.py index c38dc063..b2b18f04 100644 --- a/bot/exts/fun/game.py +++ b/bot/exts/fun/game.py @@ -2,9 +2,9 @@ import difflib import logging import random import re -from datetime import datetime as dt, timedelta +from datetime import UTC, datetime, timedelta from enum import IntEnum -from typing import Any, Optional +from typing import Any from aiohttp import ClientSession from discord import Embed @@ -255,7 +255,7 @@ class Games(Cog): self.genres[genre_name] = genre @group(name="games", aliases=("game",), invoke_without_command=True) - async def games(self, ctx: Context, amount: Optional[int] = 5, *, genre: Optional[str]) -> None: + async def games(self, ctx: Context, amount: int | None = 5, *, genre: str | None) -> None: """ Get random game(s) by genre from IGDB. Use .games genres command to get all available genres. @@ -293,7 +293,8 @@ class Games(Cog): f"{f'Maybe you meant `{display_possibilities}`?' if display_possibilities else ''}" ) return - elif len(possibilities) == 1: + + if len(possibilities) == 1: games = await self.get_games_list( amount, self.genres[possibilities[0][1]], offset=random.randint(0, 150) ) @@ -368,8 +369,8 @@ class Games(Cog): async def get_games_list( self, amount: int, - genre: Optional[str] = None, - sort: Optional[str] = None, + genre: str | None = None, + sort: str | None = None, additional_body: str = "", offset: int = 0 ) -> list[dict[str, Any]]: @@ -398,10 +399,13 @@ class Games(Cog): async def create_page(self, data: dict[str, Any]) -> tuple[str, str]: """Create content of Game Page.""" # Create cover image URL from template - url = COVER_URL.format(**{"image_id": data["cover"]["image_id"] if "cover" in data else ""}) + url = COVER_URL.format(image_id=data.get("cover", {}).get("image_id", "")) # Get release date separately with checking - release_date = dt.utcfromtimestamp(data["first_release_date"]).date() if "first_release_date" in data else "?" + if "first_release_date" in data: + release_date = datetime.fromtimestamp(data["first_release_date"], tz=UTC).date() + else: + release_date = "?" # Create Age Ratings value rating = ", ".join( @@ -434,7 +438,7 @@ class Games(Cog): lines = [] # Define request body of IGDB API request and do request - body = SEARCH_BODY.format(**{"term": search_term}) + body = SEARCH_BODY.format(term=search_term) async with self.http_session.post(url=f"{BASE_URL}/games", data=body, headers=self.headers) as resp: data = await resp.json() @@ -460,10 +464,10 @@ class Games(Cog): returning results. """ # Create request body from template - body = COMPANIES_LIST_BODY.format(**{ - "limit": limit, - "offset": offset - }) + body = COMPANIES_LIST_BODY.format( + limit=limit, + offset=offset, + ) async with self.http_session.post(url=f"{BASE_URL}/companies", data=body, headers=self.headers) as resp: return await resp.json() @@ -471,10 +475,10 @@ class Games(Cog): async def create_company_page(self, data: dict[str, Any]) -> tuple[str, str]: """Create good formatted Game Company page.""" # Generate URL of company logo - url = LOGO_URL.format(**{"image_id": data["logo"]["image_id"] if "logo" in data else ""}) + url = LOGO_URL.format(image_id=data.get("logo", {}).get("image_id", "")) # Try to get found date of company - founded = dt.utcfromtimestamp(data["start_date"]).date() if "start_date" in data else "?" + founded = datetime.fromtimestamp(data["start_date"], tz=UTC).date() if "start_date" in data else "?" # Generate list of games, that company have developed or published developed = ", ".join(game["name"] for game in data["developed"]) if "developed" in data else "?" diff --git a/bot/exts/fun/hangman.py b/bot/exts/fun/hangman.py index f385a955..7a02a552 100644 --- a/bot/exts/fun/hangman.py +++ b/bot/exts/fun/hangman.py @@ -89,7 +89,7 @@ class Hangman(commands.Cog): word = choice(filtered_words) # `pretty_word` is used for comparing the indices where the guess of the user is similar to the word # The `user_guess` variable is prettified by adding spaces between every dash, and so is the `pretty_word` - pretty_word = ''.join([f"{letter} " for letter in word])[:-1] + pretty_word = "".join([f"{letter} " for letter in word])[:-1] user_guess = ("_ " * len(word))[:-1] tries = 6 guessed_letters = set() @@ -104,7 +104,7 @@ class Hangman(commands.Cog): )) # Game loop - while user_guess.replace(' ', '') != word: + while user_guess.replace(" ", "") != word: # Edit the message to the current state of the game await original_message.edit(embed=self.create_embed(tries, user_guess)) @@ -136,7 +136,7 @@ class Hangman(commands.Cog): continue # Checks for repeated guesses - elif normalized_content in guessed_letters: + if normalized_content in guessed_letters: already_guessed_embed = Embed( title=choice(NEGATIVE_REPLIES), description=f"You have already guessed `{normalized_content}`, try again!", @@ -146,12 +146,11 @@ class Hangman(commands.Cog): continue # Checks for correct guesses from the user - elif normalized_content in word: + if normalized_content in word: positions = {idx for idx, letter in enumerate(pretty_word) if letter == normalized_content} user_guess = "".join( [normalized_content if index in positions else dash for index, dash in enumerate(user_guess)] ) - else: tries -= 1 diff --git a/bot/exts/fun/latex.py b/bot/exts/fun/latex.py index 8af05413..12f2d0b6 100644 --- a/bot/exts/fun/latex.py +++ b/bot/exts/fun/latex.py @@ -1,19 +1,22 @@ import hashlib +import logging import os import re import string from io import BytesIO from pathlib import Path -from typing import BinaryIO, Optional +from typing import BinaryIO import discord from PIL import Image +from aiohttp import web from discord.ext import commands from bot.bot import Bot from bot.constants import Channels, WHITELISTED_CHANNELS from bot.utils.decorators import whitelist_override +log = logging.getLogger(__name__) FORMATTED_CODE_REGEX = re.compile( r"(?P<delim>(?P<block>```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block r"(?(block)(?:(?P<lang>[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) @@ -45,8 +48,7 @@ def _prepare_input(text: str) -> str: """Extract latex from a codeblock, if it is in one.""" if match := FORMATTED_CODE_REGEX.match(text): return match.group("code") - else: - return text + return text def _process_image(data: bytes, out_file: BinaryIO) -> None: @@ -65,7 +67,7 @@ def _process_image(data: bytes, out_file: BinaryIO) -> None: class InvalidLatexError(Exception): """Represents an error caused by invalid latex.""" - def __init__(self, logs: Optional[str]): + def __init__(self, logs: str | None): super().__init__(logs) self.logs = logs @@ -89,7 +91,7 @@ class Latex(commands.Cog): ) as response: _process_image(await response.read(), out_file) - async def _upload_to_pastebin(self, text: str) -> Optional[str]: + async def _upload_to_pastebin(self, text: str) -> str | None: """Uploads `text` to the paste service, returning the url if successful.""" try: async with self.bot.http_session.post( @@ -100,9 +102,8 @@ class Latex(commands.Cog): response_json = await response.json() if "key" in response_json: return f"{PASTEBIN_URL}/{response_json['key']}.txt?noredirect" - except Exception: - # 400 (Bad Request) means there are too many characters - pass + except web.HTTPClientError as e: + log.info("Error when uploading latex output to pastebin. %s", e) @commands.command() @commands.max_concurrency(1, commands.BucketType.guild, wait=True) @@ -112,7 +113,7 @@ class Latex(commands.Cog): query = _prepare_input(query) # the hash of the query is used as the filename in the cache. - query_hash = hashlib.md5(query.encode()).hexdigest() + query_hash = hashlib.md5(query.encode()).hexdigest() # noqa: S324 image_path = CACHE_DIRECTORY / f"{query_hash}.png" async with ctx.typing(): if not image_path.exists(): diff --git a/bot/exts/fun/madlibs.py b/bot/exts/fun/madlibs.py index 075dde75..c14e8a3a 100644 --- a/bot/exts/fun/madlibs.py +++ b/bot/exts/fun/madlibs.py @@ -117,7 +117,7 @@ class Madlibs(commands.Cog): self.checks.remove(author_check) story = [] - for value, blank in zip(random_template["value"], blanks): + for value, blank in zip(random_template["value"], blanks, strict=True): story.append(f"{value}__{blank}__") # In each story template, there is always one more "value" diff --git a/bot/exts/fun/minesweeper.py b/bot/exts/fun/minesweeper.py index f16b1db2..69be88d3 100644 --- a/bot/exts/fun/minesweeper.py +++ b/bot/exts/fun/minesweeper.py @@ -2,7 +2,6 @@ import logging from collections.abc import Iterator from dataclasses import dataclass from random import randint, random -from typing import Union import discord from discord.ext import commands @@ -33,7 +32,7 @@ MESSAGE_MAPPING = { log = logging.getLogger(__name__) -GameBoard = list[list[Union[str, int]]] +GameBoard = list[list[str | int]] @dataclass @@ -205,9 +204,9 @@ class Minesweeper(commands.Cog): for y in range(10) ): return False - else: - await self.won(ctx) - return True + + await self.won(ctx) + return True async def reveal_one( self, @@ -227,7 +226,7 @@ class Minesweeper(commands.Cog): await self.lost(ctx) revealed[y][x] = "x" # mark bomb that made you lose with a x return True - elif board[y][x] == 0: + if board[y][x] == 0: self.reveal_zeros(revealed, board, x, y) return await self.check_if_won(ctx, revealed, board) diff --git a/bot/exts/fun/movie.py b/bot/exts/fun/movie.py index 21183e0f..3d36b119 100644 --- a/bot/exts/fun/movie.py +++ b/bot/exts/fun/movie.py @@ -63,17 +63,17 @@ class Movie(Cog): @group(name="movies", aliases=("movie",), invoke_without_command=True) async def movies(self, ctx: Context, genre: str = "", amount: int = 5) -> None: """ - Get random movies by specifying genre. Also support amount parameter,\ - that define how much movies will be shown. + Get random movies by specifying genre. - Default 5. Use .movies genres to get all available genres. + The amount parameter, that defines how many movies will be shown, defaults to 5. + Use `.movies genres` to get all available genres. """ # Check is there more than 20 movies specified, due TMDB return 20 movies # per page, so this is max. Also you can't get less movies than 1, just logic if amount > 20: await ctx.send("You can't get more than 20 movies at once. (TMDB limits)") return - elif amount < 1: + if amount < 1: await ctx.send("You can't get less than 1 movie.") return @@ -179,8 +179,8 @@ class Movie(Cog): text += "__**Some Numbers**__\n" - budget = f"{movie['budget']:,d}" if movie['budget'] else "?" - revenue = f"{movie['revenue']:,d}" if movie['revenue'] else "?" + budget = f"{movie['budget']:,d}" if movie["budget"] else "?" + revenue = f"{movie['revenue']:,d}" if movie["revenue"] else "?" if movie["runtime"] is not None: duration = divmod(movie["runtime"], 60) diff --git a/bot/exts/fun/quack.py b/bot/exts/fun/quack.py index bb0cd731..9bb024fc 100644 --- a/bot/exts/fun/quack.py +++ b/bot/exts/fun/quack.py @@ -1,6 +1,6 @@ import logging import random -from typing import Literal, Optional +from typing import Literal import discord from discord.ext import commands @@ -8,7 +8,7 @@ from discord.ext import commands from bot.bot import Bot from bot.constants import Colours, NEGATIVE_REPLIES -API_URL = 'https://quackstack.pythondiscord.com' +API_URL = "https://quackstack.pythondiscord.com" log = logging.getLogger(__name__) @@ -25,7 +25,7 @@ class Quackstack(commands.Cog): ctx: commands.Context, ducktype: Literal["duck", "manduck"] = "duck", *, - seed: Optional[str] = None + seed: str | None = None ) -> None: """ Use the Quackstack API to generate a random duck. diff --git a/bot/exts/fun/snakes/_snakes_cog.py b/bot/exts/fun/snakes/_snakes_cog.py index 892a3dd2..eca462c6 100644 --- a/bot/exts/fun/snakes/_snakes_cog.py +++ b/bot/exts/fun/snakes/_snakes_cog.py @@ -9,7 +9,7 @@ import textwrap import urllib from functools import partial from io import BytesIO -from typing import Any, Optional +from typing import Any import async_timeout from PIL import Image, ImageDraw, ImageFont @@ -274,7 +274,7 @@ class Snakes(Cog): return message - async def _fetch(self, url: str, params: Optional[dict] = None) -> dict: + async def _fetch(self, url: str, params: dict | None = None) -> dict: """Asynchronous web request helper method.""" if params is None: params = {} @@ -518,52 +518,51 @@ class Snakes(Cog): log.debug("Antidote timed out waiting for a reaction") break # We're done, no reactions for the last 5 minutes - if antidote_tries < 10: - if antidote_guess_count < 4: - if reaction.emoji in ANTIDOTE_EMOJI: - antidote_guess_list.append(reaction.emoji) - antidote_guess_count += 1 - - if antidote_guess_count == 4: # Guesses complete - antidote_guess_count = 0 - page_guess_list[antidote_tries] = " ".join(antidote_guess_list) - - # Now check guess - for i in range(0, len(antidote_answer)): - if antidote_guess_list[i] == antidote_answer[i]: - guess_result.append(TICK_EMOJI) - elif antidote_guess_list[i] in antidote_answer: - guess_result.append(BLANK_EMOJI) - else: - guess_result.append(CROSS_EMOJI) - guess_result.sort() - page_result_list[antidote_tries] = " ".join(guess_result) - - # Rebuild the board - board = [] - for i in range(0, 10): - board.append(f"`{i+1:02d}` " - f"{page_guess_list[i]} - " - f"{page_result_list[i]}") - board.append(EMPTY_UNICODE) - - # Remove Reactions - for emoji in antidote_guess_list: - await board_id.remove_reaction(emoji, user) - - if antidote_guess_list == antidote_answer: - win = True - - antidote_tries += 1 - guess_result = [] - antidote_guess_list = [] - - antidote_embed.clear_fields() - antidote_embed.add_field(name=f"{10 - antidote_tries} " - f"guesses remaining", - value="\n".join(board)) - # Redisplay the board - await board_id.edit(embed=antidote_embed) + if antidote_tries < 10 and antidote_guess_count < 4: + if reaction.emoji in ANTIDOTE_EMOJI: + antidote_guess_list.append(reaction.emoji) + antidote_guess_count += 1 + + if antidote_guess_count == 4: # Guesses complete + antidote_guess_count = 0 + page_guess_list[antidote_tries] = " ".join(antidote_guess_list) + + # Now check guess + for i in range(0, len(antidote_answer)): + if antidote_guess_list[i] == antidote_answer[i]: + guess_result.append(TICK_EMOJI) + elif antidote_guess_list[i] in antidote_answer: + guess_result.append(BLANK_EMOJI) + else: + guess_result.append(CROSS_EMOJI) + guess_result.sort() + page_result_list[antidote_tries] = " ".join(guess_result) + + # Rebuild the board + board = [] + for i in range(0, 10): + board.append(f"`{i+1:02d}` " + f"{page_guess_list[i]} - " + f"{page_result_list[i]}") + board.append(EMPTY_UNICODE) + + # Remove Reactions + for emoji in antidote_guess_list: + await board_id.remove_reaction(emoji, user) + + if antidote_guess_list == antidote_answer: + win = True + + antidote_tries += 1 + guess_result = [] + antidote_guess_list = [] + + antidote_embed.clear_fields() + antidote_embed.add_field(name=f"{10 - antidote_tries} " + f"guesses remaining", + value="\n".join(board)) + # Redisplay the board + await board_id.edit(embed=antidote_embed) # Winning / Ending Screen if win is True: @@ -746,10 +745,10 @@ class Snakes(Cog): await message.delete() # Build and send the embed. - my_snake_embed = Embed(description=":tada: Congrats! You hatched: **{0}**".format(snake_name)) + my_snake_embed = Embed(description=f":tada: Congrats! You hatched: **{snake_name}**") my_snake_embed.set_thumbnail(url=snake_image) my_snake_embed.set_footer( - text=" Owner: {0}#{1}".format(ctx.author.name, ctx.author.discriminator) + text=f" Owner: {ctx.author.name}#{ctx.author.discriminator}" ) await ctx.send(embed=my_snake_embed) @@ -832,7 +831,7 @@ class Snakes(Cog): # Prepare a question. question = random.choice(self.snake_quizzes) answer = question["answerkey"] - options = {key: question["options"][key] for key in ANSWERS_EMOJI.keys()} + options = {key: question["options"][key] for key in ANSWERS_EMOJI} # Build and send the embed. embed = Embed( @@ -879,10 +878,7 @@ class Snakes(Cog): snake_name = snake_name.split()[-1] # If no name is provided, use whoever called the command. - if name: - user_name = name - else: - user_name = ctx.author.display_name + user_name = name if name else ctx.author.display_name # Get the index of the vowel to slice the username at user_slice_index = len(user_name) @@ -1148,3 +1144,4 @@ class Snakes(Cog): embed.description = "Could not generate the snake card! Please try again." embed.title = random.choice(ERROR_REPLIES) await ctx.send(embed=embed) + # endregion diff --git a/bot/exts/fun/snakes/_utils.py b/bot/exts/fun/snakes/_utils.py index 182fa9d9..5332cde2 100644 --- a/bot/exts/fun/snakes/_utils.py +++ b/bot/exts/fun/snakes/_utils.py @@ -6,7 +6,6 @@ import math import random from itertools import product from pathlib import Path -from typing import Union from PIL import Image from PIL.ImageDraw import ImageDraw @@ -132,7 +131,7 @@ def lerp(t: float, a: float, b: float) -> float: return a + t * (b - a) -class PerlinNoiseFactory(object): +class PerlinNoiseFactory: """ Callable that produces Perlin noise for an arbitrary point in an arbitrary number of dimensions. @@ -396,7 +395,7 @@ class SnakeAndLaddersGame: Listen for reactions until players have joined, and the game has been started. """ - def startup_event_check(reaction_: Reaction, user_: Union[User, Member]) -> bool: + def startup_event_check(reaction_: Reaction, user_: User | Member) -> bool: """Make sure that this reaction is what we want to operate on.""" return ( all(( @@ -445,14 +444,12 @@ class SnakeAndLaddersGame: # Allow game author or non-playing moderation staff to cancel a waiting game await self.cancel_game() return - else: - await self.player_leave(user) - elif reaction.emoji == START_EMOJI: - if self.ctx.author == user: - self.started = True - await self.start_game(user) - await startup.delete() - break + await self.player_leave(user) + elif reaction.emoji == START_EMOJI and self.ctx.author == user: + self.started = True + await self.start_game(user) + await startup.delete() + break await startup.remove_reaction(reaction.emoji, user) @@ -461,7 +458,7 @@ class SnakeAndLaddersGame: await self.cancel_game() return # We're done, no reactions for the last 5 minutes - async def _add_player(self, user: Union[User, Member]) -> None: + async def _add_player(self, user: User | Member) -> None: """Add player to game.""" self.players.append(user) self.player_tiles[user.id] = 1 @@ -470,7 +467,7 @@ class SnakeAndLaddersGame: im = Image.open(io.BytesIO(avatar_bytes)).resize((BOARD_PLAYER_SIZE, BOARD_PLAYER_SIZE)) self.avatar_images[user.id] = im - async def player_join(self, user: Union[User, Member]) -> None: + async def player_join(self, user: User | Member) -> None: """ Handle players joining the game. @@ -496,7 +493,7 @@ class SnakeAndLaddersGame: delete_after=10 ) - async def player_leave(self, user: Union[User, Member]) -> bool: + async def player_leave(self, user: User | Member) -> bool: """ Handle players leaving the game. @@ -531,17 +528,17 @@ class SnakeAndLaddersGame: await self.channel.send("**Snakes and Ladders**: Game has been canceled.") self._destruct() - async def start_game(self, user: Union[User, Member]) -> None: + async def start_game(self, user: User | Member) -> None: """ Allow the game author to begin the game. The game cannot be started if the game is in a waiting state. """ - if not user == self.author: + if user != self.author: await self.channel.send(user.mention + " Only the author of the game can start it.", delete_after=10) return - if not self.state == "waiting": + if self.state != "waiting": await self.channel.send(user.mention + " The game cannot be started at this time.", delete_after=10) return @@ -552,7 +549,7 @@ class SnakeAndLaddersGame: async def start_round(self) -> None: """Begin the round.""" - def game_event_check(reaction_: Reaction, user_: Union[User, Member]) -> bool: + def game_event_check(reaction_: Reaction, user_: User | Member) -> bool: """Make sure that this reaction is what we want to operate on.""" return ( all(( @@ -626,8 +623,7 @@ class SnakeAndLaddersGame: # Only allow non-playing moderation staff to cancel a running game await self.cancel_game() return - else: - is_surrendered = await self.player_leave(user) + is_surrendered = await self.player_leave(user) await self.positions.remove_reaction(reaction.emoji, user) @@ -645,7 +641,7 @@ class SnakeAndLaddersGame: if not is_surrendered: await self._complete_round() - async def player_roll(self, user: Union[User, Member]) -> None: + async def player_roll(self, user: User | Member) -> None: """Handle the player's roll.""" if user.id not in self.player_tiles: await self.channel.send(user.mention + " You are not in the match.", delete_after=10) @@ -692,7 +688,7 @@ class SnakeAndLaddersGame: await self.channel.send("**Snakes and Ladders**: " + winner.mention + " has won the game! :tada:") self._destruct() - def _check_winner(self) -> Union[User, Member]: + def _check_winner(self) -> User | Member: """Return a winning member if we're in the post-round state and there's a winner.""" if self.state != "post_round": return None @@ -717,6 +713,6 @@ class SnakeAndLaddersGame: return x_level, y_level @staticmethod - def _is_moderator(user: Union[User, Member]) -> bool: + def _is_moderator(user: User | Member) -> bool: """Return True if the user is a Moderator.""" - return any(role.id in MODERATION_ROLES for role in getattr(user, 'roles', [])) + return any(role.id in MODERATION_ROLES for role in getattr(user, "roles", [])) diff --git a/bot/exts/fun/space.py b/bot/exts/fun/space.py index c688b281..3a666bfc 100644 --- a/bot/exts/fun/space.py +++ b/bot/exts/fun/space.py @@ -1,7 +1,7 @@ import logging import random -from datetime import date, datetime -from typing import Any, Optional +from datetime import UTC, date, datetime +from typing import Any from urllib.parse import urlencode from discord import Embed @@ -53,7 +53,7 @@ class Space(Cog): await self.bot.invoke_help_command(ctx) @space.command(name="apod") - async def apod(self, ctx: Context, date: Optional[str]) -> None: + async def apod(self, ctx: Context, date: str | None) -> None: """ Get Astronomy Picture of Day from NASA API. Date is optional parameter, what formatting is YYYY-MM-DD. @@ -63,13 +63,13 @@ class Space(Cog): # Parse date to params, when provided. Show error message when invalid formatting if date: try: - apod_date = datetime.strptime(date, "%Y-%m-%d").date() + apod_date = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=UTC).date() except ValueError: await ctx.send(f"Invalid date {date}. Please make sure your date is in format YYYY-MM-DD.") return - now = datetime.now().date() - if APOD_MIN_DATE > apod_date or now < apod_date: + now = datetime.now(tz=UTC).date() + if apod_date < APOD_MIN_DATE or now < apod_date: await ctx.send(f"Date must be between {APOD_MIN_DATE.isoformat()} and {now.isoformat()} (today).") return @@ -86,7 +86,7 @@ class Space(Cog): ) @space.command(name="nasa") - async def nasa(self, ctx: Context, *, search_term: Optional[str]) -> None: + async def nasa(self, ctx: Context, *, search_term: str | None) -> None: """Get random NASA information/facts + image. Support `search_term` parameter for more specific search.""" params = { "media_type": "image" @@ -111,11 +111,11 @@ class Space(Cog): ) @space.command(name="epic") - async def epic(self, ctx: Context, date: Optional[str]) -> None: + async def epic(self, ctx: Context, date: str | None) -> None: """Get a random image of the Earth from the NASA EPIC API. Support date parameter, format is YYYY-MM-DD.""" if date: try: - show_date = datetime.strptime(date, "%Y-%m-%d").date().isoformat() + show_date = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=UTC).date().isoformat() except ValueError: await ctx.send(f"Invalid date {date}. Please make sure your date is in format YYYY-MM-DD.") return @@ -147,7 +147,7 @@ class Space(Cog): async def mars( self, ctx: Context, - date: Optional[DateConverter], + date: DateConverter | None, rover: str = "curiosity" ) -> None: """ @@ -158,10 +158,8 @@ class Space(Cog): rover = rover.lower() if rover not in self.rovers: await ctx.send( - ( - f"Invalid rover `{rover}`.\n" - f"**Rovers:** `{'`, `'.join(f'{r.capitalize()}' for r in self.rovers)}`" - ) + f"Invalid rover `{rover}`.\n" + f"**Rovers:** `{'`, `'.join(f'{r.capitalize()}' for r in self.rovers)}`" ) return @@ -203,8 +201,8 @@ class Space(Cog): async def fetch_from_nasa( self, endpoint: str, - additional_params: Optional[dict[str, Any]] = None, - base: Optional[str] = NASA_BASE_URL, + additional_params: dict[str, Any] | None = None, + base: str | None = NASA_BASE_URL, use_api_key: bool = True ) -> dict[str, Any]: """Fetch information from NASA API, return result.""" @@ -219,7 +217,7 @@ class Space(Cog): async with self.http_session.get(url=f"{base}/{endpoint}?{urlencode(params)}") as resp: return await resp.json() - def create_nasa_embed(self, title: str, description: str, image: str, footer: Optional[str] = "") -> Embed: + def create_nasa_embed(self, title: str, description: str, image: str, footer: str | None = "") -> Embed: """Generate NASA commands embeds. Required: title, description and image URL, footer (addition) is optional.""" return Embed( title=title, diff --git a/bot/exts/fun/tic_tac_toe.py b/bot/exts/fun/tic_tac_toe.py index fa2a7531..d4ae7107 100644 --- a/bot/exts/fun/tic_tac_toe.py +++ b/bot/exts/fun/tic_tac_toe.py @@ -1,6 +1,6 @@ import asyncio import random -from typing import Callable, Optional, Union +from collections.abc import Callable import discord from discord.ext.commands import Cog, Context, check, group @@ -42,7 +42,7 @@ class Player: self.ctx = ctx self.symbol = symbol - async def get_move(self, board: dict[int, str], msg: discord.Message) -> tuple[bool, Optional[int]]: + async def get_move(self, board: dict[int, str], msg: discord.Message) -> tuple[bool, int | None]: """ Get move from user. @@ -106,7 +106,7 @@ class AI: class Game: """Class that contains information and functions about Tic Tac Toe game.""" - def __init__(self, players: list[Union[Player, AI]], ctx: Context): + def __init__(self, players: list[Player | AI], ctx: Context): self.players = players self.ctx = ctx self.channel = ctx.channel @@ -125,13 +125,13 @@ class Game: self.current = self.players[0] self.next = self.players[1] - self.winner: Optional[Union[Player, AI]] = None - self.loser: Optional[Union[Player, AI]] = None + self.winner: Player | AI | None = None + self.loser: Player | AI | None = None self.over = False self.canceled = False self.draw = False - async def get_confirmation(self) -> tuple[bool, Optional[str]]: + async def get_confirmation(self) -> tuple[bool, str | None]: """ Ask does user want to play TicTacToe against requester. First player is always requester. @@ -171,10 +171,10 @@ class Game: await confirm_message.delete() if reaction.emoji == Emojis.confirmation: return True, None - else: - self.over = True - self.canceled = True - return False, "User declined" + + self.over = True + self.canceled = True + return False, "User declined" @staticmethod async def add_reactions(msg: discord.Message) -> None: @@ -186,7 +186,8 @@ class Game: """Get formatted tic-tac-toe board for message.""" board = list(self.board.values()) return "\n".join( - (f"{board[line]} {board[line + 1]} {board[line + 2]}" for line in range(0, len(board), 3)) + f"{board[line]} {board[line + 1]} {board[line + 2]}" + for line in range(0, len(board), 3) ) async def play(self) -> None: @@ -256,7 +257,7 @@ class TicTacToe(Cog): @is_channel_free() @is_requester_free() @group(name="tictactoe", aliases=("ttt", "tic"), invoke_without_command=True) - async def tic_tac_toe(self, ctx: Context, opponent: Optional[discord.User]) -> None: + async def tic_tac_toe(self, ctx: Context, opponent: discord.User | None) -> None: """Tic Tac Toe game. Play against friends or AI. Use reactions to add your mark to field.""" if opponent == ctx.author: await ctx.send("You can't play against yourself.") diff --git a/bot/exts/fun/trivia_quiz.py b/bot/exts/fun/trivia_quiz.py index 31652374..28cd4657 100644 --- a/bot/exts/fun/trivia_quiz.py +++ b/bot/exts/fun/trivia_quiz.py @@ -6,10 +6,10 @@ import random import re import string from collections import defaultdict +from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from pathlib import Path -from typing import Callable, Optional import discord from discord.ext import commands, tasks @@ -249,7 +249,7 @@ class TriviaQuiz(commands.Cog): wiki_questions = [] # trivia_quiz.json follows a pattern, every new category starts with the next century. start_id = 501 - yesterday = datetime.strftime(datetime.now() - timedelta(1), '%Y/%m/%d') + yesterday = datetime.strftime(datetime.now(tz=UTC) - timedelta(1), "%Y/%m/%d") while error_fetches < MAX_ERROR_FETCH_TRIES: async with self.bot.http_session.get(url=WIKI_FEED_API_URL.format(date=yesterday)) as r: @@ -267,7 +267,7 @@ class TriviaQuiz(commands.Cog): # Normalize the wikipedia article title to remove all punctuations from it for word in re.split(r"[\s-]", title := article["normalizedtitle"]): cleaned_title = re.sub( - rf'\b{word.strip(string.punctuation)}\b', word, title, flags=re.IGNORECASE + rf"\b{word.strip(string.punctuation)}\b", word, title, flags=re.IGNORECASE ) # Since the extract contains the article name sometimes this would replace all the matching words @@ -279,7 +279,7 @@ class TriviaQuiz(commands.Cog): for word in re.split(r"[\s-]", cleaned_title): word = word.strip(string.punctuation) secret_word = r"\*" * len(word) - question = re.sub(rf'\b{word}\b', f"**{secret_word}**", question, flags=re.IGNORECASE) + question = re.sub(rf"\b{word}\b", f"**{secret_word}**", question, flags=re.IGNORECASE) formatted_article_question = { "id": start_id, @@ -307,7 +307,7 @@ class TriviaQuiz(commands.Cog): return json.loads(p.read_text(encoding="utf-8")) @commands.group(name="quiz", aliases=("trivia", "triviaquiz"), invoke_without_command=True) - async def quiz_game(self, ctx: commands.Context, category: Optional[str], questions: Optional[int]) -> None: + async def quiz_game(self, ctx: commands.Context, category: str | None, questions: int | None) -> None: """ Start a quiz! @@ -550,7 +550,7 @@ class TriviaQuiz(commands.Cog): if self.game_status[ctx.channel.id]: # Check if the author is the game starter or a moderator. if ctx.author == self.game_owners[ctx.channel.id] or any( - role.id in MODERATION_ROLES for role in getattr(ctx.author, 'roles', []) + role.id in MODERATION_ROLES for role in getattr(ctx.author, "roles", []) ): self.game_status[ctx.channel.id] = False del self.game_owners[ctx.channel.id] diff --git a/bot/exts/fun/uwu.py b/bot/exts/fun/uwu.py index 7a9d55d0..488c68f3 100644 --- a/bot/exts/fun/uwu.py +++ b/bot/exts/fun/uwu.py @@ -30,7 +30,7 @@ EMOJIS = [ "o.O", "-.-", ">w<", - "σωσ", + "σωσ", # noqa: RUF001 "òωó", "ʘwʘ", ":3", @@ -74,7 +74,7 @@ class Emoji: return bot.get_emoji(self.uid) is not None @classmethod - def from_match(cls, match: tuple[str, str, str]) -> t.Optional['Emoji']: + def from_match(cls, match: tuple[str, str, str]) -> t.Optional["Emoji"]: """Creates an Emoji from a regex match tuple.""" if not match or len(match) != 3 or not match[2].isdecimal(): return None @@ -155,7 +155,7 @@ class Uwu(Cog): return input_string @commands.command(name="uwu", aliases=("uwuwize", "uwuify",)) - async def uwu_command(self, ctx: Context, *, text: t.Optional[str] = None) -> None: + async def uwu_command(self, ctx: Context, *, text: str | None = None) -> None: """ Echo an uwuified version the passed text. diff --git a/bot/exts/fun/wonder_twins.py b/bot/exts/fun/wonder_twins.py index 0c5b0a76..9385780d 100644 --- a/bot/exts/fun/wonder_twins.py +++ b/bot/exts/fun/wonder_twins.py @@ -11,8 +11,8 @@ class WonderTwins(Cog): """Cog for a Wonder Twins inspired command.""" def __init__(self): - with open(Path.cwd() / "bot" / "resources" / "fun" / "wonder_twins.yaml", "r", encoding="utf-8") as f: - info = yaml.load(f, Loader=yaml.FullLoader) + with open(Path.cwd() / "bot" / "resources" / "fun" / "wonder_twins.yaml", encoding="utf-8") as f: + info = yaml.safe_load(f) self.water_types = info["water_types"] self.objects = info["objects"] self.adjectives = info["adjectives"] diff --git a/bot/exts/fun/xkcd.py b/bot/exts/fun/xkcd.py index 380c3c80..7b34795c 100644 --- a/bot/exts/fun/xkcd.py +++ b/bot/exts/fun/xkcd.py @@ -1,7 +1,6 @@ import logging import re from random import randint -from typing import Optional, Union from discord import Embed from discord.ext import tasks @@ -21,7 +20,7 @@ class XKCD(Cog): def __init__(self, bot: Bot): self.bot = bot - self.latest_comic_info: dict[str, Union[str, int]] = {} + self.latest_comic_info: dict[str, str | int] = {} self.get_latest_comic_info.start() def cog_unload(self) -> None: @@ -38,7 +37,7 @@ class XKCD(Cog): log.debug(f"Failed to get latest XKCD comic information. Status code {resp.status}") @command(name="xkcd") - async def fetch_xkcd_comics(self, ctx: Context, comic: Optional[str]) -> None: + async def fetch_xkcd_comics(self, ctx: Context, comic: str | None) -> None: """ Getting an xkcd comic's information along with the image. diff --git a/bot/exts/holidays/easter/bunny_name_generator.py b/bot/exts/holidays/easter/bunny_name_generator.py index 50872ebc..3034da4a 100644 --- a/bot/exts/holidays/easter/bunny_name_generator.py +++ b/bot/exts/holidays/easter/bunny_name_generator.py @@ -3,7 +3,6 @@ import logging import random import re from pathlib import Path -from typing import Optional from discord.ext import commands @@ -18,7 +17,7 @@ class BunnyNameGenerator(commands.Cog): """Generate a random bunny name, or bunnify your Discord username!""" @staticmethod - def find_separators(displayname: str) -> Optional[list[str]]: + def find_separators(displayname: str) -> list[str] | None: """Check if Discord name contains spaces so we can bunnify an individual word in the name.""" new_name = re.split(r"[_.\s]", displayname) if displayname not in new_name: @@ -26,7 +25,7 @@ class BunnyNameGenerator(commands.Cog): return None @staticmethod - def find_vowels(displayname: str) -> Optional[str]: + def find_vowels(displayname: str) -> str | None: """ Finds vowels in the user's display name. @@ -46,6 +45,7 @@ class BunnyNameGenerator(commands.Cog): new_name = re.sub(exp, vowel_sub, displayname) if new_name != displayname: return new_name + return None @staticmethod def append_name(displayname: str) -> str: @@ -77,7 +77,7 @@ class BunnyNameGenerator(commands.Cog): unmatched_name = self.append_name(username) if spaces_in_name is not None: - replacements = ["Cotton", "Fluff", "Floof" "Bounce", "Snuffle", "Nibble", "Cuddle", "Velvetpaw", "Carrot"] + replacements = ["Cotton", "Fluff", "Floof", "Bounce", "Snuffle", "Nibble", "Cuddle", "Velvetpaw", "Carrot"] word_to_replace = random.choice(spaces_in_name) substitute = random.choice(replacements) bunnified_name = username.replace(word_to_replace, substitute) diff --git a/bot/exts/holidays/easter/earth_photos.py b/bot/exts/holidays/easter/earth_photos.py index 013122c8..66f1b07b 100644 --- a/bot/exts/holidays/easter/earth_photos.py +++ b/bot/exts/holidays/easter/earth_photos.py @@ -12,7 +12,7 @@ API_URL = "https://api.unsplash.com/photos/random" class EarthPhotos(commands.Cog): - """This cog contains the command for earth photos.""" + """The earth photos cog.""" def __init__(self, bot: Bot): self.bot = bot diff --git a/bot/exts/holidays/easter/easter_riddle.py b/bot/exts/holidays/easter/easter_riddle.py index c5d7b164..6c29dd17 100644 --- a/bot/exts/holidays/easter/easter_riddle.py +++ b/bot/exts/holidays/easter/easter_riddle.py @@ -18,7 +18,7 @@ TIMELIMIT = 10 class EasterRiddle(commands.Cog): - """This cog contains the command for the Easter quiz!""" + """The Easter quiz cog.""" def __init__(self, bot: Bot): self.bot = bot diff --git a/bot/exts/holidays/easter/egg_decorating.py b/bot/exts/holidays/easter/egg_decorating.py index a9334820..1327f4d0 100644 --- a/bot/exts/holidays/easter/egg_decorating.py +++ b/bot/exts/holidays/easter/egg_decorating.py @@ -4,7 +4,6 @@ import random from contextlib import suppress from io import BytesIO from pathlib import Path -from typing import Optional, Union import discord from PIL import Image @@ -33,7 +32,7 @@ class EggDecorating(commands.Cog): """Decorate some easter eggs!""" @staticmethod - def replace_invalid(colour: str) -> Optional[int]: + def replace_invalid(colour: str) -> int | None: """Attempts to match with HTML or XKCD colour names, returning the int value.""" with suppress(KeyError): return int(HTML_COLOURS[colour], 16) @@ -43,8 +42,8 @@ class EggDecorating(commands.Cog): @commands.command(aliases=("decorateegg",)) async def eggdecorate( - self, ctx: commands.Context, *colours: Union[discord.Colour, str] - ) -> Optional[Image.Image]: + self, ctx: commands.Context, *colours: discord.Colour | str + ) -> Image.Image | None: """ Picks a random egg design and decorates it using the given colours. @@ -53,7 +52,7 @@ class EggDecorating(commands.Cog): """ if len(colours) < 2: await ctx.send("You must include at least 2 colours!") - return + return None invalid = [] colours = list(colours) @@ -68,10 +67,10 @@ class EggDecorating(commands.Cog): if len(invalid) > 1: await ctx.send(f"Sorry, I don't know these colours: {' '.join(invalid)}") - return - elif len(invalid) == 1: + return None + if len(invalid) == 1: await ctx.send(f"Sorry, I don't know the colour {invalid[0]}!") - return + return None async with ctx.typing(): # Expand list to 8 colours diff --git a/bot/exts/holidays/easter/egghead_quiz.py b/bot/exts/holidays/easter/egghead_quiz.py index 2e4d1931..046f9fac 100644 --- a/bot/exts/holidays/easter/egghead_quiz.py +++ b/bot/exts/holidays/easter/egghead_quiz.py @@ -3,7 +3,6 @@ import logging import random from json import loads from pathlib import Path -from typing import Union import discord from discord.ext import commands @@ -29,7 +28,7 @@ TIMELIMIT = 30 class EggheadQuiz(commands.Cog): - """This cog contains the command for the Easter quiz!""" + """The Egghead quiz cog.""" def __init__(self): self.quiz_messages = {} @@ -117,9 +116,10 @@ class EggheadQuiz(commands.Cog): ) await ctx.send(content, embed=a_embed) + return None @staticmethod - async def already_reacted(new_reaction: discord.Reaction, user: Union[discord.Member, discord.User]) -> bool: + async def already_reacted(new_reaction: discord.Reaction, user: discord.Member | discord.User) -> bool: """Returns whether a given user has reacted more than once to a given message.""" message = new_reaction.message for reaction in message.reactions: @@ -131,16 +131,17 @@ class EggheadQuiz(commands.Cog): return False @commands.Cog.listener() - async def on_reaction_add(self, reaction: discord.Reaction, user: Union[discord.Member, discord.User]) -> None: + async def on_reaction_add(self, reaction: discord.Reaction, user: discord.Member | discord.User) -> None: """Listener to listen specifically for reactions of quiz messages.""" if user.bot: - return + return None if reaction.message.id not in self.quiz_messages: - return + return None if str(reaction.emoji) not in self.quiz_messages[reaction.message.id]: return await reaction.message.remove_reaction(reaction, user) if await self.already_reacted(reaction, user): return await reaction.message.remove_reaction(reaction, user) + return None async def setup(bot: Bot) -> None: diff --git a/bot/exts/holidays/halloween/candy_collection.py b/bot/exts/holidays/halloween/candy_collection.py index 683114f9..ca68888b 100644 --- a/bot/exts/holidays/halloween/candy_collection.py +++ b/bot/exts/holidays/halloween/candy_collection.py @@ -1,6 +1,5 @@ import logging import random -from typing import Union import discord from async_rediscache import RedisCache @@ -18,17 +17,17 @@ ADD_CANDY_EXISTING_REACTION_CHANCE = 10 # 10% ADD_SKULL_REACTION_CHANCE = 50 # 2% ADD_SKULL_EXISTING_REACTION_CHANCE = 20 # 5% -EMOJIS = dict( - CANDY="\N{CANDY}", - SKULL="\N{SKULL}", - MEDALS=( +EMOJIS = { + "CANDY": "\N{CANDY}", + "SKULL": "\N{SKULL}", + "MEDALS": ( "\N{FIRST PLACE MEDAL}", "\N{SECOND PLACE MEDAL}", "\N{THIRD PLACE MEDAL}", "\N{SPORTS MEDAL}", "\N{SPORTS MEDAL}", ), -) +} class CandyCollection(commands.Cog): @@ -69,7 +68,7 @@ class CandyCollection(commands.Cog): @in_month(Month.OCTOBER) @commands.Cog.listener() - async def on_reaction_add(self, reaction: discord.Reaction, user: Union[discord.User, discord.Member]) -> None: + async def on_reaction_add(self, reaction: discord.Reaction, user: discord.User | discord.Member) -> None: """Add/remove candies from a person if the reaction satisfies criteria.""" message = reaction.message # check to ensure the reactor is human @@ -140,7 +139,7 @@ class CandyCollection(commands.Cog): @staticmethod async def send_spook_msg( - author: discord.Member, channel: discord.TextChannel, candies: Union[str, int] + author: discord.Member, channel: discord.TextChannel, candies: str | int ) -> None: """Send a spooky message.""" e = discord.Embed(colour=author.colour) diff --git a/bot/exts/holidays/halloween/8ball.py b/bot/exts/holidays/halloween/eight_ball.py index 21b55a01..21b55a01 100644 --- a/bot/exts/holidays/halloween/8ball.py +++ b/bot/exts/holidays/halloween/eight_ball.py diff --git a/bot/exts/holidays/halloween/monstersurvey.py b/bot/exts/holidays/halloween/monstersurvey.py index 517f1bcb..d129f3cc 100644 --- a/bot/exts/holidays/halloween/monstersurvey.py +++ b/bot/exts/holidays/halloween/monstersurvey.py @@ -9,8 +9,8 @@ from discord.ext.commands import Bot, Cog, Context log = logging.getLogger(__name__) EMOJIS = { - "SUCCESS": u"\u2705", - "ERROR": u"\u274C" + "SUCCESS": "\u2705", + "ERROR": "\u274C" } diff --git a/bot/exts/holidays/halloween/spookynamerate.py b/bot/exts/holidays/halloween/spookynamerate.py index a76e5e12..78e8be8e 100644 --- a/bot/exts/holidays/halloween/spookynamerate.py +++ b/bot/exts/holidays/halloween/spookynamerate.py @@ -2,11 +2,10 @@ import asyncio import json import random from collections import defaultdict -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from logging import getLogger from os import getenv from pathlib import Path -from typing import Optional from async_rediscache import RedisCache from discord import Embed, Reaction, TextChannel, User @@ -146,7 +145,7 @@ class SpookyNameRate(Cog): ) return - elif data["name"] == name: + if data["name"] == name: await ctx.send("TOO LATE. Someone has already added this name.") return @@ -261,12 +260,8 @@ class SpookyNameRate(Cog): winners = [] for i, winner in enumerate(winner_messages): winners.append(winner) - if len(winner_messages) > i + 1: - if winner_messages[i + 1][1]["score"] != winner[1]["score"]: - break - elif len(winner_messages) == (i + 1) + 1: # The next element is the last element - if winner_messages[i + 1][1]["score"] != winner[1]["score"]: - break + if len(winner_messages) > i + 1 and winner_messages[i + 1][1]["score"] != winner[1]["score"]: + break # one iteration is complete await channel.send("Today's Spooky Name Rate Game ends now, and the winner(s) is(are)...") @@ -313,7 +308,7 @@ class SpookyNameRate(Cog): if SpookyNameRate.debug: return - now = datetime.utcnow() + now = datetime.now(tz=UTC) if now.hour < 12: twelve_pm = now.replace(hour=12, minute=0, second=0, microsecond=0) time_left = twelve_pm - now @@ -353,7 +348,7 @@ class SpookyNameRate(Cog): return embed - async def get_channel(self) -> Optional[TextChannel]: + async def get_channel(self) -> TextChannel | None: """Gets the sir-lancebot-channel after waiting until ready.""" channel = self.bot.get_channel( Channels.sir_lancebot_playground @@ -369,7 +364,7 @@ class SpookyNameRate(Cog): return True if not Client.month_override: - return datetime.utcnow().month == Month.OCTOBER + return datetime.now(tz=UTC).month == Month.OCTOBER return Client.month_override == Month.OCTOBER def cog_check(self, ctx: Context) -> bool: diff --git a/bot/exts/holidays/hanukkah/hanukkah_embed.py b/bot/exts/holidays/hanukkah/hanukkah_embed.py index 1ebc21e8..fd8edca1 100644 --- a/bot/exts/holidays/hanukkah/hanukkah_embed.py +++ b/bot/exts/holidays/hanukkah/hanukkah_embed.py @@ -1,5 +1,5 @@ -import datetime import logging +from datetime import UTC, date, datetime from discord import Embed from discord.ext import commands @@ -21,18 +21,18 @@ class HanukkahEmbed(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - self.hanukkah_dates: list[datetime.date] = [] + self.hanukkah_dates: list[date] = [] - def _parse_time_to_datetime(self, date: list[str]) -> datetime.datetime: + def _parse_time_to_datetime(self, date: list[str]) -> datetime: """Format the times provided by the api to datetime forms.""" try: - return datetime.datetime.strptime(date, "%Y-%m-%dT%H:%M:%S%z") + return datetime.strptime(date, "%Y-%m-%dT%H:%M:%S%z") except ValueError: # there is a possibility of an event not having a time, just a day # to catch this, we try again without time information - return datetime.datetime.strptime(date, "%Y-%m-%d") + return datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=UTC) - async def fetch_hanukkah_dates(self) -> list[datetime.date]: + async def fetch_hanukkah_dates(self) -> list[date]: """Gets the dates for hanukkah festival.""" # clear the datetime objects to prevent a memory link self.hanukkah_dates = [] @@ -52,11 +52,11 @@ class HanukkahEmbed(commands.Cog): hanukkah_dates = await self.fetch_hanukkah_dates() start_day = hanukkah_dates[0] end_day = hanukkah_dates[-1] - today = datetime.date.today() + today = datetime.now(tz=UTC).date() embed = Embed(title="Hanukkah", colour=Colours.blue) if start_day <= today <= end_day: if start_day == today: - now = datetime.datetime.utcnow() + now = datetime.now(tz=UTC) hours = now.hour + 4 # using only hours hanukkah_start_hour = 18 if hours < hanukkah_start_hour: @@ -66,7 +66,8 @@ class HanukkahEmbed(commands.Cog): ) await ctx.send(embed=embed) return - elif hours > hanukkah_start_hour: + + if hours > hanukkah_start_hour: embed.description = ( "It is the starting day of Hanukkah! " f"Its been {hours - hanukkah_start_hour} hours hanukkah started!" diff --git a/bot/exts/holidays/holidayreact.py b/bot/exts/holidays/holidayreact.py index ee20b792..c3686fab 100644 --- a/bot/exts/holidays/holidayreact.py +++ b/bot/exts/holidays/holidayreact.py @@ -72,9 +72,9 @@ HOLIDAYS_TO_REACT = [ Valentines, Easter, EarthDay, Pride, Halloween, Hanukkah, Christmas ] # Type (or order) doesn't matter here - set is for de-duplication -MONTHS_TO_REACT = set( +MONTHS_TO_REACT = { month for holiday in HOLIDAYS_TO_REACT for month in holiday.months -) +} class HolidayReact(Cog): diff --git a/bot/exts/holidays/pride/pride_anthem.py b/bot/exts/holidays/pride/pride_anthem.py index 6b78cba1..c719e388 100644 --- a/bot/exts/holidays/pride/pride_anthem.py +++ b/bot/exts/holidays/pride/pride_anthem.py @@ -2,7 +2,6 @@ import json import logging import random from pathlib import Path -from typing import Optional from discord.ext import commands @@ -16,7 +15,7 @@ VIDEOS = json.loads(Path("bot/resources/holidays/pride/anthems.json").read_text( class PrideAnthem(commands.Cog): """Embed a random youtube video for a gay anthem!""" - def get_video(self, genre: Optional[str] = None) -> dict: + def get_video(self, genre: str | None = None) -> dict: """ Picks a random anthem from the list. @@ -25,12 +24,12 @@ class PrideAnthem(commands.Cog): """ if not genre: return random.choice(VIDEOS) - else: - songs = [song for song in VIDEOS if genre.casefold() in song["genre"]] - try: - return random.choice(songs) - except IndexError: - log.info("No videos for that genre.") + + songs = [song for song in VIDEOS if genre.casefold() in song["genre"]] + try: + return random.choice(songs) + except IndexError: + log.info("No videos for that genre.") @commands.command(name="prideanthem", aliases=("anthem", "pridesong")) async def prideanthem(self, ctx: commands.Context, genre: str = None) -> None: diff --git a/bot/exts/holidays/pride/pride_facts.py b/bot/exts/holidays/pride/pride_facts.py index 36a9415e..f3ce50a9 100644 --- a/bot/exts/holidays/pride/pride_facts.py +++ b/bot/exts/holidays/pride/pride_facts.py @@ -1,9 +1,8 @@ import json import logging import random -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path -from typing import Union import dateutil.parser import discord @@ -29,11 +28,11 @@ class PrideFacts(commands.Cog): async def send_pride_fact_daily(self) -> None: """Background task to post the daily pride fact every day.""" channel = self.bot.get_channel(Channels.sir_lancebot_playground) - await self.send_select_fact(channel, datetime.utcnow()) + await self.send_select_fact(channel, datetime.now(tz=UTC)) async def send_random_fact(self, ctx: commands.Context) -> None: """Provides a fact from any previous day, or today.""" - now = datetime.utcnow() + now = datetime.now(tz=UTC) previous_years_facts = (y for x, y in FACTS.items() if int(x) < now.year) current_year_facts = FACTS.get(str(now.year), [])[:now.day] previous_facts = current_year_facts + [x for y in previous_years_facts for x in y] @@ -42,9 +41,9 @@ class PrideFacts(commands.Cog): except IndexError: await ctx.send("No facts available") - async def send_select_fact(self, target: discord.abc.Messageable, _date: Union[str, datetime]) -> None: + async def send_select_fact(self, target: discord.abc.Messageable, _date: str | datetime) -> None: """Provides the fact for the specified day, if the day is today, or is in the past.""" - now = datetime.utcnow() + now = datetime.now(tz=UTC) if isinstance(_date, str): try: date = dateutil.parser.parse(_date, dayfirst=False, yearfirst=False, fuzzy=True) @@ -76,7 +75,7 @@ class PrideFacts(commands.Cog): will be provided. """ if not option: - await self.send_select_fact(ctx, datetime.utcnow()) + await self.send_select_fact(ctx, datetime.now(tz=UTC)) elif option.lower().startswith("rand"): await self.send_random_fact(ctx) else: diff --git a/bot/exts/holidays/pride/pride_leader.py b/bot/exts/holidays/pride/pride_leader.py index 120e9e16..b4a98892 100644 --- a/bot/exts/holidays/pride/pride_leader.py +++ b/bot/exts/holidays/pride/pride_leader.py @@ -2,7 +2,6 @@ import json import logging import random from pathlib import Path -from typing import Optional import discord from discord.ext import commands @@ -90,7 +89,7 @@ class PrideLeader(commands.Cog): return embed @commands.command(aliases=("pl", "prideleader")) - async def pride_leader(self, ctx: commands.Context, *, pride_leader_name: Optional[str]) -> None: + async def pride_leader(self, ctx: commands.Context, *, pride_leader_name: str | None) -> None: """ Information about a Pride Leader. diff --git a/bot/exts/holidays/valentines/be_my_valentine.py b/bot/exts/holidays/valentines/be_my_valentine.py index 3a2beef3..c2dd8bb6 100644 --- a/bot/exts/holidays/valentines/be_my_valentine.py +++ b/bot/exts/holidays/valentines/be_my_valentine.py @@ -126,15 +126,14 @@ class BeMyValentine(commands.Cog): if valentine_type is None: return self.random_valentine() - elif valentine_type.lower() in ["p", "poem"]: + if valentine_type.lower() in ["p", "poem"]: return self.valentine_poem(), "A poem dedicated to" - elif valentine_type.lower() in ["c", "compliment"]: + if valentine_type.lower() in ["c", "compliment"]: return self.valentine_compliment(), "A compliment for" - else: - # in this case, the user decides to type his own valentine. - return valentine_type, "A message for" + # in this case, the user decides to type his own valentine. + return valentine_type, "A message for" @staticmethod def random_emoji() -> tuple[str, str]: @@ -148,10 +147,7 @@ class BeMyValentine(commands.Cog): valentine_poem = random.choice(self.valentines["valentine_poems"]) valentine_compliment = random.choice(self.valentines["valentine_compliments"]) random_valentine = random.choice([valentine_compliment, valentine_poem]) - if random_valentine == valentine_poem: - title = "A poem dedicated to" - else: - title = "A compliment for " + title = "A poem dedicated to" if random_valentine == valentine_poem else "A compliment for " return random_valentine, title def valentine_poem(self) -> str: diff --git a/bot/exts/holidays/valentines/lovecalculator.py b/bot/exts/holidays/valentines/lovecalculator.py index eab3d083..9b363c5e 100644 --- a/bot/exts/holidays/valentines/lovecalculator.py +++ b/bot/exts/holidays/valentines/lovecalculator.py @@ -3,8 +3,8 @@ import hashlib import json import logging import random +from collections.abc import Coroutine from pathlib import Path -from typing import Coroutine, Optional import discord from discord import Member @@ -27,7 +27,7 @@ class LoveCalculator(Cog): @in_month(Month.FEBRUARY) @commands.command(aliases=("love_calculator", "love_calc")) @commands.cooldown(rate=1, per=5, type=commands.BucketType.user) - async def love(self, ctx: commands.Context, who: Member, whom: Optional[Member] = None) -> None: + async def love(self, ctx: commands.Context, who: Member, whom: Member | None = None) -> None: """ Tells you how much the two love each other. diff --git a/bot/exts/holidays/valentines/myvalenstate.py b/bot/exts/holidays/valentines/myvalenstate.py index 8d8772d4..fcef24bc 100644 --- a/bot/exts/holidays/valentines/myvalenstate.py +++ b/bot/exts/holidays/valentines/myvalenstate.py @@ -47,7 +47,7 @@ class MyValenstate(commands.Cog): else: author = name.lower().replace(" ", "") - for state in STATES.keys(): + for state in STATES: lower_state = state.lower().replace(" ", "") eq_chars[state] = self.levenshtein(author, lower_state) @@ -64,8 +64,7 @@ class MyValenstate(commands.Cog): embed_text = f"You have another match, this being {matches[0]}." else: embed_title = "You have a true match!" - embed_text = "This state is your true Valenstate! There are no states that would suit" \ - " you better" + embed_text = "This state is your true Valenstate! There are no states that would suit you better" embed = discord.Embed( title=f"Your Valenstate is {valenstate} \u2764", diff --git a/bot/exts/holidays/valentines/valentine_zodiac.py b/bot/exts/holidays/valentines/valentine_zodiac.py index 0a28a5c5..f0c8978d 100644 --- a/bot/exts/holidays/valentines/valentine_zodiac.py +++ b/bot/exts/holidays/valentines/valentine_zodiac.py @@ -2,9 +2,8 @@ import calendar import json import logging import random -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path -from typing import Union import discord from discord.ext import commands @@ -78,6 +77,7 @@ class ValentineZodiac(commands.Cog): if zodiac_data["start_at"].date() <= query_date.date() <= zodiac_data["end_at"].date(): log.trace("Zodiac name sent.") return zodiac_name + return None @commands.group(name="zodiac", invoke_without_command=True) async def zodiac(self, ctx: commands.Context, zodiac_sign: str) -> None: @@ -87,7 +87,7 @@ class ValentineZodiac(commands.Cog): log.trace("Embed successfully sent.") @zodiac.command(name="date") - async def date_and_month(self, ctx: commands.Context, date: int, month: Union[int, str]) -> None: + async def date_and_month(self, ctx: commands.Context, date: int, month: int | str) -> None: """Provides information about zodiac sign by taking month and date as input.""" if isinstance(month, str): month = month.capitalize() @@ -103,7 +103,7 @@ class ValentineZodiac(commands.Cog): final_embed = self.zodiac_build_embed(zodiac) else: try: - zodiac_sign_based_on_date = self.zodiac_date_verifier(datetime(2020, month, date)) + zodiac_sign_based_on_date = self.zodiac_date_verifier(datetime(2020, month, date, tzinfo=UTC)) log.trace("zodiac sign based on month and date received.") except ValueError as e: final_embed = discord.Embed() diff --git a/bot/exts/utilities/bookmark.py b/bot/exts/utilities/bookmark.py index 65a32203..150dfc48 100644 --- a/bot/exts/utilities/bookmark.py +++ b/bot/exts/utilities/bookmark.py @@ -1,6 +1,5 @@ import logging import random -from typing import Optional import discord from discord.ext import commands @@ -145,15 +144,15 @@ class Bookmark(commands.Cog): This command allows deleting any message sent by Sir-Lancebot in the user's DM channel with the bot. The command invocation must be a reply to the message that is to be deleted. """ - target_message: Optional[discord.Message] = getattr(ctx.message.reference, "resolved", None) + target_message: discord.Message | None = getattr(ctx.message.reference, "resolved", None) if target_message is None: raise commands.UserInputError("You must reply to the message from Sir-Lancebot you wish to delete.") if not isinstance(ctx.channel, discord.DMChannel): raise commands.UserInputError("You can only run this command your own DMs!") - elif target_message.channel != ctx.channel: + if target_message.channel != ctx.channel: raise commands.UserInputError("You can only delete messages in your own DMs!") - elif target_message.author != self.bot.user: + if target_message.author != self.bot.user: raise commands.UserInputError("You can only delete messages sent by Sir Lancebot!") await target_message.delete() diff --git a/bot/exts/utilities/challenges.py b/bot/exts/utilities/challenges.py index 46bc0fae..2f9ac73e 100644 --- a/bot/exts/utilities/challenges.py +++ b/bot/exts/utilities/challenges.py @@ -1,7 +1,6 @@ import logging from asyncio import to_thread from random import choice -from typing import Union from bs4 import BeautifulSoup from discord import Embed, Interaction, SelectOption, ui @@ -58,7 +57,7 @@ class InformationDropdown(ui.Select): SelectOption( label="Other Information", description="See how other people performed on this kata and more!", - emoji="ℹ" + emoji="🇮" ) ] @@ -101,7 +100,7 @@ class Challenges(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - async def kata_id(self, search_link: str, params: dict) -> Union[str, Embed]: + async def kata_id(self, search_link: str, params: dict) -> str | Embed: """ Uses bs4 to get the HTML code for the page of katas, where the page is the link of the formatted `search_link`. @@ -123,14 +122,13 @@ class Challenges(commands.Cog): if not first_kata_div: raise commands.BadArgument("No katas could be found with the filters provided.") - else: - first_kata_div = choice(first_kata_div) # There are numerous divs before arriving at the id of the kata, which can be used for the link. + first_kata_div = choice(first_kata_div) first_kata_id = first_kata_div.a["href"].split("/")[-1] return first_kata_id - async def kata_information(self, kata_id: str) -> Union[dict, Embed]: + async def kata_information(self, kata_id: str) -> dict | Embed: """ Returns the information about the Kata. diff --git a/bot/exts/utilities/cheatsheet.py b/bot/exts/utilities/cheatsheet.py index d7eb4917..98ce3f57 100644 --- a/bot/exts/utilities/cheatsheet.py +++ b/bot/exts/utilities/cheatsheet.py @@ -1,6 +1,5 @@ import random import re -from typing import Union from urllib.parse import quote_plus from discord import Embed @@ -52,7 +51,7 @@ class CheatSheet(commands.Cog): ) return embed - def result_fmt(self, url: str, body_text: str) -> tuple[bool, Union[str, Embed]]: + def result_fmt(self, url: str, body_text: str) -> tuple[bool, str | Embed]: """Format Result.""" if body_text.startswith("# 404 NOT FOUND"): embed = self.fmt_error_embed() diff --git a/bot/exts/utilities/colour.py b/bot/exts/utilities/colour.py index 20f97e4b..b8f1d62d 100644 --- a/bot/exts/utilities/colour.py +++ b/bot/exts/utilities/colour.py @@ -4,7 +4,6 @@ import pathlib import random import string from io import BytesIO -from typing import Optional import discord import rapidfuzz @@ -25,7 +24,7 @@ class Colour(commands.Cog): self.bot = bot with open(pathlib.Path("bot/resources/utilities/ryanzec_colours.json")) as f: self.colour_mapping = json.load(f) - del self.colour_mapping['_'] # Delete source credit entry + del self.colour_mapping["_"] # Delete source credit entry async def send_colour_response(self, ctx: commands.Context, rgb: tuple[int, int, int]) -> None: """Create and send embed from user given colour information.""" @@ -84,7 +83,7 @@ class Colour(commands.Cog): roles=constants.STAFF_ROLES, categories=[constants.Categories.development, constants.Categories.media] ) - async def colour(self, ctx: commands.Context, *, colour_input: Optional[str] = None) -> None: + async def colour(self, ctx: commands.Context, *, colour_input: str | None = None) -> None: """ Create an embed that displays colour information. @@ -209,7 +208,7 @@ class Colour(commands.Cog): def _rgb_to_hsl(rgb: tuple[int, int, int]) -> tuple[int, int, int]: """Convert RGB values to HSL values.""" rgb_list = [val / 255.0 for val in rgb] - h, l, s = colorsys.rgb_to_hls(*rgb_list) + h, l, s = colorsys.rgb_to_hls(*rgb_list) # noqa: E741 hsl = (round(h * 360), round(s * 100), round(l * 100)) return hsl @@ -233,7 +232,7 @@ class Colour(commands.Cog): hex_code = f"#{hex_}".upper() return hex_code - def _rgb_to_name(self, rgb: tuple[int, int, int]) -> Optional[str]: + def _rgb_to_name(self, rgb: tuple[int, int, int]) -> str | None: """Convert RGB values to a fuzzy matched name.""" input_hex_colour = self._rgb_to_hex(rgb) try: @@ -247,7 +246,7 @@ class Colour(commands.Cog): colour_name = None return colour_name - def match_colour_name(self, ctx: commands.Context, input_colour_name: str) -> Optional[str]: + def match_colour_name(self, ctx: commands.Context, input_colour_name: str) -> str | None: """Convert a colour name to HEX code.""" try: match, certainty, _ = rapidfuzz.process.extractOne( @@ -256,7 +255,7 @@ class Colour(commands.Cog): score_cutoff=80 ) except (ValueError, TypeError): - return + return None return f"#{self.colour_mapping[match]}" diff --git a/bot/exts/utilities/conversationstarters.py b/bot/exts/utilities/conversationstarters.py index 410ea884..a019c789 100644 --- a/bot/exts/utilities/conversationstarters.py +++ b/bot/exts/utilities/conversationstarters.py @@ -2,7 +2,6 @@ import asyncio from contextlib import suppress from functools import partial from pathlib import Path -from typing import Union import discord import yaml @@ -16,11 +15,11 @@ from bot.utils.randomization import RandomCycle SUGGESTION_FORM = "https://forms.gle/zw6kkJqv8U43Nfjg9" with Path("bot/resources/utilities/starter.yaml").open("r", encoding="utf8") as f: - STARTERS = yaml.load(f, Loader=yaml.FullLoader) + STARTERS = yaml.safe_load(f) with Path("bot/resources/utilities/py_topics.yaml").open("r", encoding="utf8") as f: # First ID is #python-general and the rest are top to bottom categories of Topical Chat/Help. - PY_TOPICS = yaml.load(f, Loader=yaml.FullLoader) + PY_TOPICS = yaml.safe_load(f) # Removing `None` from lists of topics, if not a list, it is changed to an empty one. PY_TOPICS = {k: [i for i in v if i] if isinstance(v, list) else [] for k, v in PY_TOPICS.items()} @@ -67,7 +66,7 @@ class ConvoStarters(commands.Cog): @staticmethod def _predicate( - command_invoker: Union[discord.User, discord.Member], + command_invoker: discord.User | discord.Member, message: discord.Message, reaction: discord.Reaction, user: discord.User @@ -84,7 +83,7 @@ class ConvoStarters(commands.Cog): async def _listen_for_refresh( self, - command_invoker: Union[discord.User, discord.Member], + command_invoker: discord.User | discord.Member, message: discord.Message ) -> None: await message.add_reaction("🔄") diff --git a/bot/exts/utilities/emoji.py b/bot/exts/utilities/emoji.py index ec40be01..ce352fe2 100644 --- a/bot/exts/utilities/emoji.py +++ b/bot/exts/utilities/emoji.py @@ -2,8 +2,7 @@ import logging import random import textwrap from collections import defaultdict -from datetime import datetime -from typing import Optional +from datetime import UTC, datetime from discord import Color, Embed, Emoji from discord.ext import commands @@ -28,7 +27,7 @@ class Emojis(commands.Cog): embed = Embed( color=Colours.orange, title="Emoji Count", - timestamp=datetime.utcnow() + timestamp=datetime.now(tz=UTC) ) msg = [] @@ -71,7 +70,7 @@ class Emojis(commands.Cog): return embed, msg @commands.group(name="emoji", invoke_without_command=True) - async def emoji_group(self, ctx: commands.Context, emoji: Optional[Emoji]) -> None: + async def emoji_group(self, ctx: commands.Context, emoji: Emoji | None) -> None: """A group of commands related to emojis.""" if emoji is not None: await ctx.invoke(self.info_command, emoji) diff --git a/bot/exts/utilities/epoch.py b/bot/exts/utilities/epoch.py index 6f572640..d1ba98bb 100644 --- a/bot/exts/utilities/epoch.py +++ b/bot/exts/utilities/epoch.py @@ -1,4 +1,5 @@ -from typing import Optional, Union + +import contextlib import arrow import discord @@ -24,7 +25,7 @@ DROPDOWN_TIMEOUT = 60 class DateString(commands.Converter): """Convert a relative or absolute date/time string to an arrow.Arrow object.""" - async def convert(self, ctx: commands.Context, argument: str) -> Union[arrow.Arrow, Optional[tuple]]: + async def convert(self, ctx: commands.Context, argument: str) -> arrow.Arrow | tuple | None: """ Convert a relative or absolute date/time string to an arrow.Arrow object. @@ -88,10 +89,8 @@ class Epoch(commands.Cog): view = TimestampMenuView(ctx, self._format_dates(date_time), epoch) original = await ctx.send(f"`{epoch}`", view=view) await view.wait() # wait until expiration before removing the dropdown - try: + with contextlib.suppress(discord.NotFound): await original.edit(view=None) - except discord.NotFound: # disregard the error message if the message is deleled - pass @staticmethod def _format_dates(date: arrow.Arrow) -> list[str]: @@ -100,7 +99,7 @@ class Epoch(commands.Cog): These are used in the description of each style in the dropdown """ - date = date.to('utc') + date = date.to("utc") formatted = [str(int(date.timestamp()))] formatted += [date.format(format[1]) for format in list(STYLES.values())[1:7]] formatted.append(date.humanize()) @@ -115,7 +114,7 @@ class TimestampMenuView(discord.ui.View): self.ctx = ctx self.epoch = epoch self.dropdown: discord.ui.Select = self.children[0] - for label, date_time in zip(STYLES.keys(), formatted_times): + for label, date_time in zip(STYLES.keys(), formatted_times, strict=True): self.dropdown.add_option(label=label, description=date_time) @discord.ui.select(placeholder="Select the format of your timestamp") diff --git a/bot/exts/utilities/githubinfo.py b/bot/exts/utilities/githubinfo.py index 4e008e9f..74120f2d 100644 --- a/bot/exts/utilities/githubinfo.py +++ b/bot/exts/utilities/githubinfo.py @@ -1,9 +1,8 @@ import logging import random import re -import typing as t from dataclasses import dataclass -from datetime import datetime +from datetime import UTC, datetime from urllib.parse import quote import discord @@ -48,7 +47,7 @@ AUTOMATIC_REGEX = re.compile( class FoundIssue: """Dataclass representing an issue found by the regex.""" - organisation: t.Optional[str] + organisation: str | None repository: str number: str @@ -89,7 +88,7 @@ class GithubInfo(commands.Cog): number: int, repository: str, user: str - ) -> t.Union[IssueState, FetchError]: + ) -> IssueState | FetchError: """ Retrieve an issue from a GitHub repository. @@ -105,9 +104,9 @@ class GithubInfo(commands.Cog): log.info(f"Ratelimit reached while fetching {url}") return FetchError(403, "Ratelimit reached, please retry in a few minutes.") return FetchError(403, "Cannot access issue.") - elif r.status in (404, 410): + if r.status in (404, 410): return FetchError(r.status, "Issue not found.") - elif r.status != 200: + if r.status != 200: return FetchError(r.status, "Error while fetching issue.") # The initial API request is made to the issues API endpoint, which will return information @@ -141,7 +140,7 @@ class GithubInfo(commands.Cog): @staticmethod def format_embed( - results: t.List[t.Union[IssueState, FetchError]] + results: list[IssueState | FetchError] ) -> discord.Embed: """Take a list of IssueState or FetchError and format a Discord embed for them.""" description_list = [] @@ -261,7 +260,7 @@ class GithubInfo(commands.Cog): description=f"```\n{user_data['bio']}\n```\n" if user_data["bio"] else "", colour=discord.Colour.og_blurple(), url=user_data["html_url"], - timestamp=datetime.strptime(user_data["created_at"], "%Y-%m-%dT%H:%M:%SZ") + timestamp=datetime.strptime(user_data["created_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) ) embed.set_thumbnail(url=user_data["avatar_url"]) embed.set_footer(text="Account created at") @@ -293,7 +292,7 @@ class GithubInfo(commands.Cog): await ctx.send(embed=embed) - @github_group.command(name='repository', aliases=('repo',)) + @github_group.command(name="repository", aliases=("repo",)) async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None: """ Fetches a repositories' GitHub information. @@ -347,8 +346,12 @@ class GithubInfo(commands.Cog): icon_url=repo_owner["avatar_url"] ) - repo_created_at = datetime.strptime(repo_data["created_at"], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y") - last_pushed = datetime.strptime(repo_data["pushed_at"], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y at %H:%M") + repo_created_at = datetime.strptime( + repo_data["created_at"], "%Y-%m-%dT%H:%M:%SZ" + ).replace(tzinfo=UTC).strftime("%d/%m/%Y") + last_pushed = datetime.strptime( + repo_data["pushed_at"], "%Y-%m-%dT%H:%M:%SZ" + ).replace(tzinfo=UTC).strftime("%d/%m/%Y at %H:%M") embed.set_footer( text=( diff --git a/bot/exts/utilities/realpython.py b/bot/exts/utilities/realpython.py index 46b02866..c63fca85 100644 --- a/bot/exts/utilities/realpython.py +++ b/bot/exts/utilities/realpython.py @@ -1,6 +1,5 @@ import logging from html import unescape -from typing import Optional from urllib.parse import quote_plus from discord import Embed @@ -31,8 +30,13 @@ class RealPython(commands.Cog): @commands.command(aliases=["rp"]) @commands.cooldown(1, 10, commands.cooldowns.BucketType.user) - async def realpython(self, ctx: commands.Context, amount: Optional[int] = 5, *, - user_search: Optional[str] = None) -> None: + async def realpython( + self, + ctx: commands.Context, + amount: int | None = 5, + *, + user_search: str | None = None + ) -> None: """ Send some articles from RealPython that match the search terms. diff --git a/bot/exts/utilities/reddit.py b/bot/exts/utilities/reddit.py index f7c196ae..f8e358de 100644 --- a/bot/exts/utilities/reddit.py +++ b/bot/exts/utilities/reddit.py @@ -3,8 +3,7 @@ import logging import random import textwrap from collections import namedtuple -from datetime import datetime, timedelta -from typing import Union +from datetime import UTC, datetime, timedelta from aiohttp import BasicAuth, ClientError from discord import Colour, Embed, TextChannel @@ -43,8 +42,8 @@ class Reddit(Cog): async def cog_unload(self) -> None: """Stop the loop task and revoke the access token when the cog is unloaded.""" self.auto_poster_loop.cancel() - if self.access_token and self.access_token.expires_at > datetime.utcnow(): - asyncio.create_task(self.revoke_access_token()) + if self.access_token and self.access_token.expires_at > datetime.now(tz=UTC): + await self.revoke_access_token() async def cog_load(self) -> None: """Sets the reddit webhook when the cog is loaded.""" @@ -55,7 +54,7 @@ class Reddit(Cog): """Get the #reddit channel object from the bot's cache.""" return self.bot.get_channel(Channels.reddit) - def build_pagination_pages(self, posts: list[dict], paginate: bool) -> Union[list[tuple], str]: + def build_pagination_pages(self, posts: list[dict], paginate: bool) -> list[tuple] | str: """Build embed pages required for Paginator.""" pages = [] first_page = "" @@ -138,17 +137,15 @@ class Reddit(Cog): expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway. self.access_token = AccessToken( token=content["access_token"], - expires_at=datetime.utcnow() + timedelta(seconds=expiration) + expires_at=datetime.now(tz=UTC) + timedelta(seconds=expiration) ) log.debug(f"New token acquired; expires on UTC {self.access_token.expires_at}") return - else: - log.debug( - f"Failed to get an access token: " - f"status {response.status} & content type {response.content_type}; " - f"retrying ({i}/{self.MAX_RETRIES})" - ) + log.debug( + f"Failed to get an access token: status {response.status} & content type {response.content_type}; " + f"retrying ({i}/{self.MAX_RETRIES})" + ) await asyncio.sleep(3) @@ -183,7 +180,7 @@ class Reddit(Cog): raise ValueError("Invalid amount of subreddit posts requested.") # Renew the token if necessary. - if not self.access_token or self.access_token.expires_at < datetime.utcnow(): + if not self.access_token or self.access_token.expires_at < datetime.now(tz=UTC): await self.get_access_token() url = f"{self.OAUTH_URL}/{route}" @@ -193,7 +190,7 @@ class Reddit(Cog): headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"}, params=params ) - if response.status == 200 and response.content_type == 'application/json': + if response.status == 200 and response.content_type == "application/json": # Got appropriate response - process and return. content = await response.json() posts = content["data"]["children"] @@ -205,11 +202,11 @@ class Reddit(Cog): await asyncio.sleep(3) log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") - return list() # Failed to get appropriate response within allowed number of retries. + return [] # Failed to get appropriate response within allowed number of retries. async def get_top_posts( self, subreddit: Subreddit, time: str = "all", amount: int = 5, paginate: bool = False - ) -> Union[Embed, list[tuple]]: + ) -> Embed | list[tuple]: """ Get the top amount of posts for a given subreddit within a specified timeframe. @@ -248,7 +245,7 @@ class Reddit(Cog): """Post the top 5 posts daily, and the top 5 posts weekly.""" # once d.py get support for `time` parameter in loop decorator, # this can be removed and the loop can use the `time=datetime.time.min` parameter - now = datetime.utcnow() + now = datetime.now(tz=UTC) tomorrow = now + timedelta(days=1) midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) @@ -257,7 +254,7 @@ class Reddit(Cog): if not self.webhook: await self.bot.fetch_webhook(RedditConfig.webhook) - if datetime.utcnow().weekday() == 0: + if datetime.now(tz=UTC).weekday() == 0: await self.top_weekly_posts() # if it's a monday send the top weekly posts diff --git a/bot/exts/utilities/stackoverflow.py b/bot/exts/utilities/stackoverflow.py index b248e83f..1eeff45b 100644 --- a/bot/exts/utilities/stackoverflow.py +++ b/bot/exts/utilities/stackoverflow.py @@ -42,10 +42,10 @@ class Stackoverflow(commands.Cog): if response.status == 200: data = await response.json() else: - logger.error(f'Status code is not 200, it is {response.status}') + logger.error(f"Status code is not 200, it is {response.status}") await ctx.send(embed=ERR_EMBED) return - if not data['items']: + if not data["items"]: no_search_result = Embed( title=f"No search results found for {search_query}", color=Colours.soft_red @@ -63,7 +63,7 @@ class Stackoverflow(commands.Cog): ) for item in top5: embed.add_field( - name=unescape(item['title']), + name=unescape(item["title"]), value=( f"[{Emojis.reddit_upvote} {item['score']} " f"{Emojis.stackoverflow_views} {item['view_count']} " diff --git a/bot/exts/utilities/twemoji.py b/bot/exts/utilities/twemoji.py index 25a03d25..a936f733 100644 --- a/bot/exts/utilities/twemoji.py +++ b/bot/exts/utilities/twemoji.py @@ -1,6 +1,6 @@ import logging import re -from typing import Literal, Optional +from typing import Literal import discord from discord.ext import commands @@ -57,7 +57,7 @@ class Twemoji(commands.Cog): return embed @staticmethod - def emoji(codepoint: Optional[str]) -> Optional[str]: + def emoji(codepoint: str | None) -> str | None: """ Returns the emoji corresponding to a given `codepoint`, or `None` if no emoji was found. @@ -66,9 +66,10 @@ class Twemoji(commands.Cog): """ if code := Twemoji.trim_code(codepoint): return chr(int(code, 16)) + return None @staticmethod - def codepoint(emoji: Optional[str]) -> Optional[str]: + def codepoint(emoji: str | None) -> str | None: """ Returns the codepoint, in a trimmed format, of a single emoji. @@ -82,7 +83,7 @@ class Twemoji(commands.Cog): return hex(ord(emoji)).removeprefix("0x") @staticmethod - def trim_code(codepoint: Optional[str]) -> Optional[str]: + def trim_code(codepoint: str | None) -> str | None: """ Returns the meaningful information from the given `codepoint`. @@ -98,6 +99,7 @@ class Twemoji(commands.Cog): """ if code := CODEPOINT_REGEX.search(codepoint or ""): return code.group() + return None @staticmethod def codepoint_from_input(raw_emoji: tuple[str, ...]) -> str: diff --git a/bot/exts/utilities/wikipedia.py b/bot/exts/utilities/wikipedia.py index d87982c9..d12cb19d 100644 --- a/bot/exts/utilities/wikipedia.py +++ b/bot/exts/utilities/wikipedia.py @@ -1,6 +1,6 @@ import logging import re -from datetime import datetime +from datetime import UTC, datetime from html import unescape from discord import Color, Embed, TextChannel @@ -85,7 +85,7 @@ class WikipediaSearch(commands.Cog): colour=Color.og_blurple() ) embed.set_thumbnail(url=WIKI_THUMBNAIL) - embed.timestamp = datetime.utcnow() + embed.timestamp = datetime.now(tz=UTC) await LinePaginator.paginate(contents, ctx, embed, restrict_to_user=ctx.author) else: await ctx.send( diff --git a/bot/exts/utilities/wolfram.py b/bot/exts/utilities/wolfram.py index 21029d47..d5669c6b 100644 --- a/bot/exts/utilities/wolfram.py +++ b/bot/exts/utilities/wolfram.py @@ -1,6 +1,6 @@ import logging +from collections.abc import Callable from io import BytesIO -from typing import Callable, Optional from urllib.parse import urlencode import arrow @@ -64,10 +64,10 @@ def custom_cooldown(*ignore: int) -> Callable: if ctx.invoked_with == "help": # if the invoked command is help we don't want to increase the ratelimits since it's not actually # invoking the command/making a request, so instead just check if the user/guild are on cooldown. - guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown + guild_cooldown = guildcd.get_bucket(ctx.message).get_tokens() != 0 # if guild is on cooldown # check the message is in a guild, and check user bucket if user is not ignored if ctx.guild and not any(r.id in ignore for r in ctx.author.roles): - return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 + return guild_cooldown and usercd.get_bucket(ctx.message).get_tokens() != 0 return guild_cooldown user_bucket = usercd.get_bucket(ctx.message) @@ -105,7 +105,7 @@ def custom_cooldown(*ignore: int) -> Callable: return check(predicate) -async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[list[tuple[str, str]]]: +async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> list[tuple[str, str]] | None: """Get the Wolfram API pod pages for the provided query.""" async with ctx.typing(): params = { @@ -253,10 +253,7 @@ class Wolfram(Cog): if not pages: return - if len(pages) >= 2: - page = pages[1] - else: - page = pages[0] + page = pages[1] if len(pages) >= 2 else pages[0] await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1]) diff --git a/bot/exts/utilities/wtf_python.py b/bot/exts/utilities/wtf_python.py index 0c0375cb..29a9611c 100644 --- a/bot/exts/utilities/wtf_python.py +++ b/bot/exts/utilities/wtf_python.py @@ -1,7 +1,6 @@ import logging import random import re -from typing import Optional import rapidfuzz from discord import Embed, File @@ -67,7 +66,7 @@ class WTFPython(commands.Cog): hyper_link = match[0].split("(")[1].replace(")", "") self.headers[match[0]] = f"{BASE_URL}/{hyper_link}" - def fuzzy_match_header(self, query: str) -> Optional[str]: + def fuzzy_match_header(self, query: str) -> str | None: """ Returns the fuzzy match of a query if its ratio is above "MINIMUM_CERTAINTY" else returns None. @@ -79,7 +78,7 @@ class WTFPython(commands.Cog): return match if certainty > MINIMUM_CERTAINTY else None @commands.command(aliases=("wtf",)) - async def wtf_python(self, ctx: commands.Context, *, query: Optional[str] = None) -> None: + async def wtf_python(self, ctx: commands.Context, *, query: str | None = None) -> None: """ Search WTF Python repository. diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 91682dbc..ddc2d111 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -3,8 +3,7 @@ import contextlib import re import string from collections.abc import Iterable -from datetime import datetime -from typing import Optional +from datetime import UTC, datetime import discord from discord.ext.commands import BadArgument, Context @@ -27,8 +26,7 @@ def resolve_current_month() -> Month: """ if Client.month_override is not None: return Month(Client.month_override) - else: - return Month(datetime.utcnow().month) + return Month(datetime.now(tz=UTC).month) async def disambiguate( @@ -38,7 +36,7 @@ async def disambiguate( timeout: float = 30, entries_per_page: int = 20, empty: bool = False, - embed: Optional[discord.Embed] = None + embed: discord.Embed | None = None ) -> str: """ Has the user choose between multiple entries in case one could not be chosen automatically. @@ -130,9 +128,9 @@ def replace_many( assert var == "That WAS a sentence" """ if ignore_case: - replacements = dict( - (word.lower(), replacement) for word, replacement in replacements.items() - ) + replacements = { + word.lower(): replacement for word, replacement in replacements.items() + } words_to_replace = sorted(replacements, key=lambda s: (-len(s), s)) @@ -152,10 +150,9 @@ def replace_many( cleaned_word = word.translate(str.maketrans("", "", string.punctuation)) if cleaned_word.isupper(): return replacement.upper() - elif cleaned_word[0].isupper(): + if cleaned_word[0].isupper(): return replacement.capitalize() - else: - return replacement.lower() + return replacement.lower() return regex.sub(_repl, sentence) diff --git a/bot/utils/checks.py b/bot/utils/checks.py index f21d2ddd..418bb7ad 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -1,7 +1,6 @@ import datetime import logging -from collections.abc import Container, Iterable -from typing import Callable, Optional +from collections.abc import Callable, Container, Iterable from discord.ext.commands import ( BucketType, CheckFailure, Cog, Command, CommandOnCooldown, Context, Cooldown, CooldownMapping @@ -15,7 +14,7 @@ log = logging.getLogger(__name__) class InWhitelistCheckFailure(CheckFailure): """Raised when the `in_whitelist` check fails.""" - def __init__(self, redirect_channel: Optional[int]): + def __init__(self, redirect_channel: int | None): self.redirect_channel = redirect_channel if redirect_channel: @@ -33,7 +32,7 @@ def in_whitelist_check( channels: Container[int] = (), categories: Container[int] = (), roles: Container[int] = (), - redirect: Optional[int] = constants.Channels.sir_lancebot_playground, + redirect: int | None = constants.Channels.sir_lancebot_playground, fail_silently: bool = False, ) -> bool: """ @@ -153,7 +152,7 @@ def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketTy return # Cooldown logic, taken from discord.py internals. - current = ctx.message.created_at.replace(tzinfo=datetime.timezone.utc).timestamp() + current = ctx.message.created_at.replace(tzinfo=datetime.UTC).timestamp() bucket = buckets.get_bucket(ctx.message) retry_after = bucket.update_rate_limit(current) if retry_after: diff --git a/bot/utils/converters.py b/bot/utils/converters.py index 7227a406..6111b87d 100644 --- a/bot/utils/converters.py +++ b/bot/utils/converters.py @@ -1,5 +1,4 @@ -from datetime import datetime -from typing import Union +from datetime import UTC, datetime import discord from discord.ext import commands @@ -47,7 +46,7 @@ class CoordinateConverter(commands.Converter): return x, y -SourceType = Union[commands.Command, commands.Cog] +SourceType = commands.Command | commands.Cog class SourceConverter(commands.Converter): @@ -73,12 +72,12 @@ class DateConverter(commands.Converter): """Parse SOL or earth date (in format YYYY-MM-DD) into `int` or `datetime`. When invalid input, raise error.""" @staticmethod - async def convert(ctx: commands.Context, argument: str) -> Union[int, datetime]: + async def convert(ctx: commands.Context, argument: str) -> int | datetime: """Parse date (SOL or earth) into `datetime` or `int`. When invalid value, raise error.""" if argument.isdecimal(): return int(argument) try: - date = datetime.strptime(argument, "%Y-%m-%d") + date = datetime.strptime(argument, "%Y-%m-%d").replace(tzinfo=UTC) except ValueError: raise commands.BadArgument( f"Can't convert `{argument}` to `datetime` in format `YYYY-MM-DD` or `int` in SOL." diff --git a/bot/utils/decorators.py b/bot/utils/decorators.py index 442eb841..1cbad504 100644 --- a/bot/utils/decorators.py +++ b/bot/utils/decorators.py @@ -3,9 +3,8 @@ import functools import logging import random from asyncio import Lock -from collections.abc import Container +from collections.abc import Callable, Container from functools import wraps -from typing import Callable, Optional, Union from weakref import WeakValueDictionary from discord import Colour, Embed @@ -24,16 +23,12 @@ log = logging.getLogger(__name__) class InChannelCheckFailure(CheckFailure): """Check failure when the user runs a command in a non-whitelisted channel.""" - pass - class InMonthCheckFailure(CheckFailure): """Check failure for when a command is invoked outside of its allowed month.""" - pass - -def seasonal_task(*allowed_months: Month, sleep_time: Union[float, int] = ONE_DAY) -> Callable: +def seasonal_task(*allowed_months: Month, sleep_time: float | int = ONE_DAY) -> Callable: """ Perform the decorated method periodically in `allowed_months`. @@ -79,8 +74,8 @@ def in_month_listener(*allowed_months: Month) -> Callable: if current_month in allowed_months: # Propagate return value although it should always be None return await listener(*args, **kwargs) - else: - log.debug(f"Guarded {listener.__qualname__} from invoking in {current_month!s}") + log.debug(f"Guarded {listener.__qualname__} from invoking in {current_month!s}") + return None return guarded_listener return decorator @@ -101,8 +96,7 @@ def in_month_command(*allowed_months: Month) -> Callable: ) if can_run: return True - else: - raise InMonthCheckFailure(f"Command can only be used in {human_months(allowed_months)}") + raise InMonthCheckFailure(f"Command can only be used in {human_months(allowed_months)}") return commands.check(predicate) @@ -201,13 +195,13 @@ def whitelist_check(**default_kwargs: Container[int]) -> Callable[[Context], boo # Determine which command's overrides we will use. Group commands will # inherit from their parents if they don't define their own overrides - overridden_command: Optional[commands.Command] = None + overridden_command: commands.Command | None = None for command in [ctx.command, *ctx.command.parents]: if hasattr(command.callback, "override"): overridden_command = command break if overridden_command is not None: - log.debug(f'Command {overridden_command} has overrides') + log.debug(f"Command {overridden_command} has overrides") if overridden_command is not ctx.command: log.debug( f"Command '{ctx.command.qualified_name}' inherited overrides " @@ -319,7 +313,7 @@ def whitelist_override(bypass_defaults: bool = False, allow_dm: bool = False, ** return inner -def locked() -> Optional[Callable]: +def locked() -> Callable | None: """ Allows the user to only run one instance of the decorated command at a time. @@ -327,11 +321,11 @@ def locked() -> Optional[Callable]: This decorator has to go before (below) the `command` decorator. """ - def wrap(func: Callable) -> Optional[Callable]: + def wrap(func: Callable) -> Callable | None: func.__locks = WeakValueDictionary() @wraps(func) - async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Optional[Callable]: + async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Callable | None: lock = func.__locks.setdefault(ctx.author.id, Lock()) if lock.locked(): embed = Embed() @@ -344,7 +338,7 @@ def locked() -> Optional[Callable]: ) embed.title = random.choice(ERROR_REPLIES) await ctx.send(embed=embed) - return + return None async with func.__locks.setdefault(ctx.author.id, Lock()): return await func(self, ctx, *args, **kwargs) diff --git a/bot/utils/exceptions.py b/bot/utils/exceptions.py index 3cd96325..b1a35e63 100644 --- a/bot/utils/exceptions.py +++ b/bot/utils/exceptions.py @@ -1,16 +1,13 @@ -from typing import Optional class UserNotPlayingError(Exception): """Raised when users try to use game commands when they are not playing.""" - pass - class APIError(Exception): """Raised when an external API (eg. Wikipedia) returns an error response.""" - def __init__(self, api: str, status_code: int, error_msg: Optional[str] = None): + def __init__(self, api: str, status_code: int, error_msg: str | None = None): super().__init__() self.api = api self.status_code = status_code diff --git a/bot/utils/messages.py b/bot/utils/messages.py index b0c95583..4fb0b39b 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,6 +1,7 @@ +import contextlib import logging import re -from typing import Callable, Optional, Union +from collections.abc import Callable from discord import Embed, Message from discord.ext import commands @@ -9,39 +10,35 @@ from discord.ext.commands import Context, MessageConverter log = logging.getLogger(__name__) -def sub_clyde(username: Optional[str]) -> Optional[str]: +def sub_clyde(username: str | None) -> str | None: """ - Replace "e"/"E" in any "clyde" in `username` with a Cyrillic "е"/"E" and return the new string. + Replace "e"/"E" in any "clyde" in `username` with a Cyrillic "е"/"Е" and return the new string. Discord disallows "clyde" anywhere in the username for webhooks. It will return a 400. Return None only if `username` is None. - """ + """ # noqa: RUF002 def replace_e(match: re.Match) -> str: - char = "е" if match[2] == "e" else "Е" + char = "е" if match[2] == "e" else "Е" # noqa: RUF001 return match[1] + char if username: return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I) - else: - return username # Empty string or None + return username # Empty string or None -async def get_discord_message(ctx: Context, text: str) -> Union[Message, str]: +async def get_discord_message(ctx: Context, text: str) -> Message | str: """ Attempts to convert a given `text` to a discord Message object and return it. Conversion will succeed if given a discord Message ID or link. Returns `text` if the conversion fails. """ - try: + with contextlib.suppress(commands.BadArgument): text = await MessageConverter().convert(ctx, text) - except commands.BadArgument: - pass - return text -async def get_text_and_embed(ctx: Context, text: str) -> tuple[str, Optional[Embed]]: +async def get_text_and_embed(ctx: Context, text: str) -> tuple[str, Embed | None]: """ Attempts to extract the text and embed from a possible link to a discord Message. @@ -52,7 +49,7 @@ async def get_text_and_embed(ctx: Context, text: str) -> tuple[str, Optional[Emb str: If `text` is a valid discord Message, the contents of the message, else `text`. Optional[Embed]: The embed if found in the valid Message, else None """ - embed: Optional[Embed] = None + embed: Embed | None = None msg = await get_discord_message(ctx, text) # Ensure the user has read permissions for the channel the message is in diff --git a/bot/utils/pagination.py b/bot/utils/pagination.py index b291f7db..df0eb942 100644 --- a/bot/utils/pagination.py +++ b/bot/utils/pagination.py @@ -1,7 +1,6 @@ import asyncio import logging from collections.abc import Iterable -from typing import Optional from discord import Embed, Member, Reaction from discord.abc import User @@ -29,10 +28,10 @@ class LinePaginator(Paginator): def __init__( self, - prefix: str = '```', - suffix: str = '```', + prefix: str = "```", + suffix: str = "```", max_size: int = 2000, - max_lines: Optional[int] = None, + max_lines: int | None = None, linesep: str = "\n" ): """ @@ -87,11 +86,13 @@ class LinePaginator(Paginator): self._count += 1 @classmethod - async def paginate(cls, lines: Iterable[str], ctx: Context, embed: Embed, - prefix: str = "", suffix: str = "", max_lines: Optional[int] = None, - max_size: int = 500, empty: bool = True, restrict_to_user: User = None, - timeout: int = 300, footer_text: str = None, url: str = None, - exception_on_empty_embed: bool = False) -> None: + async def paginate( + cls, lines: Iterable[str], ctx: Context, + embed: Embed, prefix: str = "", suffix: str = "", + max_lines: int | None = None, max_size: int = 500, empty: bool = True, + restrict_to_user: User = None, timeout: int = 300, footer_text: str = None, + url: str = None, exception_on_empty_embed: bool = False + ) -> None: """ Use a paginator and set of reactions to provide pagination over a set of lines. @@ -170,20 +171,20 @@ class LinePaginator(Paginator): log.debug("There's less than two pages, so we won't paginate - sending single page on its own") await ctx.send(embed=embed) - return + return None + + if footer_text: + embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") else: - if footer_text: - embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") - else: - embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") - log.trace(f"Setting embed footer to '{embed.footer.text}'") + embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") + log.trace(f"Setting embed footer to '{embed.footer.text}'") - if url: - embed.url = url - log.trace(f"Setting embed url to '{url}'") + if url: + embed.url = url + log.trace(f"Setting embed url to '{url}'") - log.debug("Sending first page to channel...") - message = await ctx.send(embed=embed) + log.debug("Sending first page to channel...") + message = await ctx.send(embed=embed) log.debug("Adding emoji reactions to message...") @@ -270,6 +271,7 @@ class LinePaginator(Paginator): log.debug("Ending pagination and clearing reactions...") await message.clear_reactions() + return None class ImagePaginator(Paginator): @@ -358,7 +360,7 @@ class ImagePaginator(Paginator): if len(paginator.pages) <= 1: await ctx.send(embed=embed) - return + return None embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") message = await ctx.send(embed=embed) @@ -431,3 +433,4 @@ class ImagePaginator(Paginator): log.debug("Ending pagination and clearing reactions...") await message.clear_reactions() + return None diff --git a/bot/utils/randomization.py b/bot/utils/randomization.py index c9eabbd2..1caff3fa 100644 --- a/bot/utils/randomization.py +++ b/bot/utils/randomization.py @@ -1,7 +1,9 @@ import itertools import random from collections.abc import Iterable -from typing import Any +from typing import TypeVar + +T = TypeVar("T") class RandomCycle: @@ -11,11 +13,11 @@ class RandomCycle: The iterable is reshuffled after each full cycle. """ - def __init__(self, iterable: Iterable): + def __init__(self, iterable: Iterable[T]): self.iterable = list(iterable) self.index = itertools.cycle(range(len(iterable))) - def __next__(self) -> Any: + def __next__(self) -> T: idx = next(self.index) if idx == 0: diff --git a/bot/utils/time.py b/bot/utils/time.py index fbf2fd21..66f9e7cb 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,4 +1,4 @@ -import datetime +from datetime import UTC, datetime from dateutil.relativedelta import relativedelta @@ -17,12 +17,11 @@ def _stringify_time_unit(value: int, unit: str) -> str: """ if unit == "seconds" and value == 0: return "0 seconds" - elif value == 1: + if value == 1: return f"{value} {unit[:-1]}" - elif value == 0: + if value == 0: return f"less than a {unit[:-1]}" - else: - return f"{value} {unit}" + return f"{value} {unit}" def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str: @@ -69,14 +68,14 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: return humanized -def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6) -> str: +def time_since(past_datetime: datetime, precision: str = "seconds", max_units: int = 6) -> str: """ Takes a datetime and returns a human-readable string that describes how long ago that datetime was. precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). """ - now = datetime.datetime.utcnow() + now = datetime.now(tz=UTC) delta = abs(relativedelta(now, past_datetime)) humanized = humanize_delta(delta, precision, max_units) |