diff options
38 files changed, 588 insertions, 469 deletions
@@ -20,6 +20,7 @@ fuzzywuzzy = "*" "flake8-todo" = "*" "flake8-string-format" = "*" pre-commit = "*" +flake8-docstrings = "*" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index 582d2a10..bedfcbc1 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "a024c71a482341a28d5107025d16ab28b3c0f049f998c5bc33a2da21ceab4d4e" + "sha256": "fddb61c7f26286f78d74b41302f5afab456b3630bd93796030690dd87b22dec3" }, "pipfile-spec": 6, "requires": { @@ -353,6 +353,14 @@ "index": "pypi", "version": "==18.8.0" }, + "flake8-docstrings": { + "hashes": [ + "sha256:4e0ce1476b64e6291520e5570cf12b05016dd4e8ae454b8a8a9a48bc5f84e1cd", + "sha256:8436396b5ecad51a122a2c99ba26e5b4e623bf6e913b0fea0cb6c2c4050f91eb" + ], + "index": "pypi", + "version": "==1.3.0" + }, "flake8-import-order": { "hashes": [ "sha256:90a80e46886259b9c396b578d75c749801a41ee969a235e163cfe1be7afd2543", @@ -361,6 +369,13 @@ "index": "pypi", "version": "==0.18.1" }, + "flake8-polyfill": { + "hashes": [ + "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9", + "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda" + ], + "version": "==1.0.2" + }, "flake8-string-format": { "hashes": [ "sha256:68ea72a1a5b75e7018cae44d14f32473c798cf73d75cbaed86c6a9a907b770b2", @@ -426,6 +441,14 @@ ], "version": "==2.5.0" }, + "pydocstyle": { + "hashes": [ + "sha256:2258f9b0df68b97bf3a6c29003edc5238ff8879f1efb6f1999988d934e432bd8", + "sha256:5741c85e408f9e0ddf873611085e819b809fca90b619f5fd7f34bd4959da3dd4", + "sha256:ed79d4ec5e92655eccc21eb0c6cf512e69512b4a97d215ace46d17e4990f2039" + ], + "version": "==3.0.0" + }, "pyflakes": { "hashes": [ "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", @@ -435,19 +458,19 @@ }, "pyyaml": { "hashes": [ - "sha256:3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b", - "sha256:3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf", - "sha256:40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a", - "sha256:558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3", - "sha256:a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1", - "sha256:aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1", - "sha256:bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613", - "sha256:d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04", - "sha256:d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f", - "sha256:e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537", - "sha256:e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531" + "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c", + "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95", + "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2", + "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4", + "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad", + "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba", + "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1", + "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e", + "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673", + "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13", + "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19" ], - "version": "==3.13" + "version": "==5.1" }, "six": { "hashes": [ @@ -456,6 +479,13 @@ ], "version": "==1.12.0" }, + "snowballstemmer": { + "hashes": [ + "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128", + "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89" + ], + "version": "==1.2.1" + }, "toml": { "hashes": [ "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", @@ -16,6 +16,8 @@ __all__ = ('SeasonalBot',) class SeasonalBot(Bot): + """Base bot instance.""" + def __init__(self, **kwargs): super().__init__(**kwargs) self.http_session = ClientSession( @@ -26,9 +28,7 @@ class SeasonalBot(Bot): ) def load_extensions(self, exts: List[str]): - """ - Unload all current cogs, then load in the ones passed into `cogs` - """ + """Unload all current extensions, then load the given extensions.""" # Unload all cogs extensions = list(self.extensions.keys()) @@ -46,9 +46,8 @@ class SeasonalBot(Bot): log.error(f'Failed to load extension {cog}: {repr(e)} {format_exc()}') async def send_log(self, title: str, details: str = None, *, icon: str = None): - """ - Send an embed message to the devlog channel - """ + """Send an embed message to the devlog channel.""" + devlog = self.get_channel(constants.Channels.devlog) if not devlog: @@ -64,7 +63,8 @@ class SeasonalBot(Bot): await devlog.send(embed=embed) async def on_command_error(self, context, exception): - # Don't punish the user for getting the arguments wrong + """Check command errors for UserInputError and reset the cooldown if thrown.""" + if isinstance(exception, commands.UserInputError): context.command.reset_cooldown(context) else: diff --git a/bot/decorators.py b/bot/decorators.py index f5ffadf4..15f7fed2 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -14,6 +14,8 @@ log = logging.getLogger(__name__) def with_role(*role_ids: int): + """Check to see whether the invoking user has any of the roles specified in role_ids.""" + async def predicate(ctx: Context): if not ctx.guild: # Return False in a DM log.debug(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " @@ -32,6 +34,8 @@ def with_role(*role_ids: int): def without_role(*role_ids: int): + """Check whether the invoking user does not have all of the roles specified in role_ids.""" + async def predicate(ctx: Context): if not ctx.guild: # Return False in a DM log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " @@ -47,6 +51,8 @@ def without_role(*role_ids: int): def in_channel(channel_id): + """Check that the command invocation is in the channel specified by channel_id.""" + async def predicate(ctx: Context): check = ctx.channel.id == channel_id log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " @@ -58,8 +64,8 @@ def in_channel(channel_id): def locked(): """ Allows the user to only run one instance of the decorated command at a time. - Subsequent calls to the command from the same author are - ignored until the command has completed invocation. + + Subsequent calls to the command from the same author are ignored until the command has completed invocation. This decorator has to go before (below) the `command` decorator. """ diff --git a/bot/pagination.py b/bot/pagination.py index 0ad5b81f..1091878a 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -18,7 +18,7 @@ log = logging.getLogger(__name__) class EmptyPaginatorEmbed(Exception): - pass + """Base Exception class for an empty paginator embed.""" class LinePaginator(Paginator): @@ -37,14 +37,13 @@ class LinePaginator(Paginator): The maximum amount of lines allowed in a page. """ - def __init__(self, prefix='```', suffix='```', - max_size=2000, max_lines=None): + def __init__(self, prefix='```', suffix='```', max_size=2000, max_lines=None): """ - This function overrides the Paginator.__init__ - from inside discord.ext.commands. - It overrides in order to allow us to configure - the maximum number of lines per page. + Overrides the Paginator.__init__ from inside discord.ext.commands. + + Allows for configuration of the maximum number of lines per page. """ + self.prefix = prefix self.suffix = suffix self.max_size = max_size - len(suffix) @@ -55,15 +54,13 @@ class LinePaginator(Paginator): self._pages = [] def add_line(self, line='', *, empty=False): - """Adds a line to the current page. + """ + Adds a line to the current page. - If the line exceeds the :attr:`max_size` then an exception - is raised. + If the line exceeds the `max_size` then an exception is raised. - This function overrides the Paginator.add_line - from inside discord.ext.commands. - It overrides in order to allow us to configure - the maximum number of lines per page. + Overrides the Paginator.add_line from inside discord.ext.commands in order to allow + configuration of the maximum number of lines per page. Parameters ----------- @@ -75,8 +72,9 @@ class LinePaginator(Paginator): Raises ------ RuntimeError - The line was too big for the current :attr:`max_size`. + The line was too big for the current `max_size`. """ + if len(line) > self.max_size - len(self.prefix) - 2: raise RuntimeError('Line exceeds maximum page size %s' % (self.max_size - len(self.prefix) - 2)) @@ -98,21 +96,26 @@ class LinePaginator(Paginator): @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): + 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): """ - Use a paginator and set of reactions to provide pagination over a set of lines. The reactions are used to - switch page, or to finish with pagination. - When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may - be used to change page, or to remove pagination from the message. Pagination will also be removed automatically - if no reaction is added for five minutes (300 seconds). + Use a paginator and set of reactions to provide pagination over a set of lines. + + The reactions are used to switch page, or to finish with pagination. + + When used, this will send a message using `ctx.send()` and apply a set of reactions to it. + These reactions may be used to change page, or to remove pagination from the message. + Pagination will also be removed automatically if no reaction is added for five minutes (300 seconds). + >>> embed = Embed() >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) >>> await LinePaginator.paginate( ... (line for line in lines), ... ctx, embed ... ) + :param lines: The lines to be paginated :param ctx: Current context object :param embed: A pre-configured embed to be used as a template for each page @@ -129,9 +132,7 @@ class LinePaginator(Paginator): """ def event_check(reaction_: Reaction, user_: Member): - """ - Make sure that this reaction is what we want to operate on - """ + """Make sure that this reaction is what we want to operate on.""" no_restrictions = ( # Pagination is not restricted @@ -301,6 +302,7 @@ class LinePaginator(Paginator): class ImagePaginator(Paginator): """ Helper class that paginates images for embeds in messages. + Close resemblance to LinePaginator, except focuses on images over text. Refer to ImagePaginator.paginate for documentation on how to use. @@ -314,7 +316,8 @@ class ImagePaginator(Paginator): def add_line(self, line: str = '', *, empty: bool = False) -> None: """ - Adds a line to each page, usually just 1 line in this context + Adds a line to each page, usually just 1 line in this context. + :param line: str to be page content / title :param empty: if there should be new lines between entries """ @@ -328,7 +331,8 @@ class ImagePaginator(Paginator): def add_image(self, image: str = None) -> None: """ - Adds an image to a page + Adds an image to a page. + :param image: image url to be appended """ @@ -339,16 +343,14 @@ class ImagePaginator(Paginator): prefix: str = "", suffix: str = "", timeout: int = 300, exception_on_empty_embed: bool = False): """ - Use a paginator and set of reactions to provide - pagination over a set of title/image pairs.The reactions are - used to switch page, or to finish with pagination. + Use a paginator and set of reactions to provide pagination over a set of title/image pairs. - When used, this will send a message using `ctx.send()` and - apply a set of reactions to it. These reactions may - be used to change page, or to remove pagination from the message. + The reactions are used to switch page, or to finish with pagination. - Note: Pagination will be removed automatically - if no reaction is added for five minutes (300 seconds). + When used, this will send a message using `ctx.send()` and apply a set of reactions to it. + These reactions may be used to change page, or to remove pagination from the message. + + Note: Pagination will be removed automatically if no reaction is added for five minutes (300 seconds). >>> embed = Embed() >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) @@ -366,7 +368,8 @@ class ImagePaginator(Paginator): def check_event(reaction_: Reaction, member: Member) -> bool: """ - Checks each reaction added, if it matches our conditions pass the wait_for + Checks each reaction added, if it matches our conditions pass the wait_for. + :param reaction_: reaction added :param member: reaction added by member """ diff --git a/bot/seasons/christmas/__init__.py b/bot/seasons/christmas/__init__.py index 99d81b0c..f0a7c2c6 100644 --- a/bot/seasons/christmas/__init__.py +++ b/bot/seasons/christmas/__init__.py @@ -4,12 +4,15 @@ from bot.seasons import SeasonBase class Christmas(SeasonBase): """ - We are getting into the festive spirit with a new server icon, new - bot name and avatar, and some new commands for you to check out! + Christmas seasonal event attributes. - No matter who you are, where you are or what beliefs you may follow, - we hope every one of you enjoy this festive season! + We are getting into the festive spirit with a new server icon, new bot name and avatar, and some + new commands for you to check out! + + No matter who you are, where you are or what beliefs you may follow, we hope every one of you + enjoy this festive season! """ + name = "christmas" bot_name = "Merrybot" greeting = "Happy Holidays!" diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py index a926a6cb..2fd474db 100644 --- a/bot/seasons/christmas/adventofcode.py +++ b/bot/seasons/christmas/adventofcode.py @@ -25,19 +25,15 @@ COUNTDOWN_STEP = 60 * 5 def is_in_advent() -> bool: - """ - Utility function to check if we are between December 1st - and December 25th. - """ + """Utility function to check if we are between December 1st and December 25th.""" + # Run the code from the 1st to the 24th return datetime.now(EST).day in range(1, 25) and datetime.now(EST).month == 12 def time_left_to_aoc_midnight() -> Tuple[datetime, timedelta]: - """ - This calculates the amount of time left until midnight in - UTC-5 (Advent of Code maintainer timezone). - """ + """Calculates the amount of time left until midnight in UTC-5 (Advent of Code maintainer timezone).""" + # Change all time properties back to 00:00 todays_midnight = datetime.now(EST).replace(microsecond=0, second=0, @@ -52,10 +48,8 @@ def time_left_to_aoc_midnight() -> Tuple[datetime, timedelta]: async def countdown_status(bot: commands.Bot): - """ - Every `COUNTDOWN_STEP` seconds set the playing status of the bot to - the number of minutes & hours left until the next day's release. - """ + """Set the playing status of the bot to the minutes & hours left until the next day's challenge.""" + while is_in_advent(): _, time_left = time_left_to_aoc_midnight() @@ -83,11 +77,12 @@ async def countdown_status(bot: commands.Bot): async def day_countdown(bot: commands.Bot): """ - Calculate the number of seconds left until the next day of advent. Once - we have calculated this we should then sleep that number and when the time - is reached ping the advent of code role notifying them that the new task is - ready. + Calculate the number of seconds left until the next day of advent. + + Once we have calculated this we should then sleep that number and when the time is reached, ping + the Advent of Code role notifying them that the new challenge is ready. """ + while is_in_advent(): tomorrow, time_left = time_left_to_aoc_midnight() @@ -109,9 +104,8 @@ async def day_countdown(bot: commands.Bot): class AdventOfCode: - """ - Advent of Code festivities! Ho Ho Ho! - """ + """Advent of Code festivities! Ho Ho Ho.""" + def __init__(self, bot: commands.Bot): self.bot = bot @@ -136,9 +130,7 @@ class AdventOfCode: @commands.group(name="adventofcode", aliases=("aoc",), invoke_without_command=True) async def adventofcode_group(self, ctx: commands.Context): - """ - All of the Advent of Code commands - """ + """All of the Advent of Code commands.""" await ctx.invoke(self.bot.get_command("help"), "adventofcode") @@ -148,9 +140,8 @@ class AdventOfCode: brief="Notifications for new days" ) async def aoc_subscribe(self, ctx: commands.Context): - """ - Assign the role for notifications about new days being ready. - """ + """Assign the role for notifications about new days being ready.""" + role = ctx.guild.get_role(AocConfig.role_id) unsubscribe_command = f"{ctx.prefix}{ctx.command.root_parent} unsubscribe" @@ -164,9 +155,8 @@ class AdventOfCode: @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") async def aoc_unsubscribe(self, ctx: commands.Context): - """ - Remove the role for notifications about new days being ready. - """ + """Remove the role for notifications about new days being ready.""" + role = ctx.guild.get_role(AocConfig.role_id) if role in ctx.author.roles: @@ -177,9 +167,8 @@ class AdventOfCode: @adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day") async def aoc_countdown(self, ctx: commands.Context): - """ - Return time left until next day - """ + """Return time left until next day.""" + if not is_in_advent(): datetime_now = datetime.now(EST) december_first = datetime(datetime_now.year + 1, 12, 1, tzinfo=EST) @@ -196,17 +185,13 @@ class AdventOfCode: @adventofcode_group.command(name="about", aliases=("ab", "info"), brief="Learn about Advent of Code") async def about_aoc(self, ctx: commands.Context): - """ - Respond with an explanation of all things Advent of Code - """ + """Respond with an explanation of all things Advent of Code.""" await ctx.send("", embed=self.cached_about_aoc) @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join PyDis' private AoC leaderboard") async def join_leaderboard(self, ctx: commands.Context): - """ - DM the user the information for joining the PyDis AoC private leaderboard - """ + """DM the user the information for joining the PyDis AoC private leaderboard.""" author = ctx.message.author log.info(f"{author.name} ({author.id}) has requested the PyDis AoC leaderboard code") @@ -228,7 +213,7 @@ class AdventOfCode: ) async def aoc_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10): """ - Pull the top number_of_people_to_display members from the PyDis leaderboard and post an embed + Pull the top number_of_people_to_display members from the PyDis leaderboard and post an embed. For readability, number_of_people_to_display defaults to 10. A maximum value is configured in the Advent of Code section of the bot constants. number_of_people_to_display values greater than this @@ -270,7 +255,7 @@ class AdventOfCode: ) async def private_leaderboard_daily_stats(self, ctx: commands.Context): """ - Respond with a table of the daily completion statistics for the PyDis private leaderboard + Respond with a table of the daily completion statistics for the PyDis private leaderboard. Embed will display the total members and the number of users who have completed each day's puzzle """ @@ -314,7 +299,7 @@ class AdventOfCode: ) async def global_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10): """ - Pull the top number_of_people_to_display members from the global AoC leaderboard and post an embed + Pull the top number_of_people_to_display members from the global AoC leaderboard and post an embed. For readability, number_of_people_to_display defaults to 10. A maximum value is configured in the Advent of Code section of the bot constants. number_of_people_to_display values greater than this @@ -347,7 +332,7 @@ class AdventOfCode: async def _check_leaderboard_cache(self, ctx, global_board: bool = False): """ - Check age of current leaderboard & pull a new one if the board is too old + Check age of current leaderboard & pull a new one if the board is too old. global_board is a boolean to toggle between the global board and the Pydis private board """ @@ -404,9 +389,7 @@ class AdventOfCode: return number_of_people_to_display def _build_about_embed(self) -> discord.Embed: - """ - Build and return the informational "About AoC" embed from the resources file - """ + """Build and return the informational "About AoC" embed from the resources file.""" with self.about_aoc_filepath.open("r") as f: embed_fields = json.load(f) @@ -421,9 +404,8 @@ class AdventOfCode: return about_embed async def _boardgetter(self, global_board: bool): - """ - Invoke the proper leaderboard getter based on the global_board boolean - """ + """Invoke the proper leaderboard getter based on the global_board boolean.""" + if global_board: self.cached_global_leaderboard = await AocGlobalLeaderboard.from_url() else: @@ -431,6 +413,8 @@ class AdventOfCode: class AocMember: + """Object representing the Advent of Code user.""" + def __init__(self, name: str, aoc_id: int, stars: int, starboard: list, local_score: int, global_score: int): self.name = name self.aoc_id = aoc_id @@ -441,12 +425,14 @@ class AocMember: self.completions = self._completions_from_starboard(self.starboard) def __repr__(self): + """Generate a user-friendly representation of the AocMember & their score.""" + return f"<{self.name} ({self.aoc_id}): {self.local_score}>" @classmethod def member_from_json(cls, injson: dict) -> "AocMember": """ - Generate an AocMember from AoC's private leaderboard API JSON + Generate an AocMember from AoC's private leaderboard API JSON. injson is expected to be the dict contained in: @@ -467,7 +453,7 @@ class AocMember: @staticmethod def _starboard_from_json(injson: dict) -> list: """ - Generate starboard from AoC's private leaderboard API JSON + Generate starboard from AoC's private leaderboard API JSON. injson is expected to be the dict contained in: @@ -500,9 +486,7 @@ class AocMember: @staticmethod def _completions_from_starboard(starboard: list) -> tuple: - """ - Return days completed, as a (1 star, 2 star) tuple, from starboard - """ + """Return days completed, as a (1 star, 2 star) tuple, from starboard.""" completions = [0, 0] for day in starboard: @@ -515,6 +499,8 @@ class AocMember: class AocPrivateLeaderboard: + """Object representing the Advent of Code private leaderboard.""" + def __init__(self, members: list, owner_id: int, event_year: int): self.members = members self._owner_id = owner_id @@ -534,7 +520,7 @@ class AocPrivateLeaderboard: def calculate_daily_completion(self) -> List[tuple]: """ - Calculate member completion rates by day + Calculate member completion rates by day. Return a list of tuples for each day containing the number of users who completed each part of the challenge @@ -560,7 +546,7 @@ class AocPrivateLeaderboard: leaderboard_id: int = AocConfig.leaderboard_id, year: int = AocConfig.year ) -> "AocPrivateLeaderboard": """ - Request the API JSON from Advent of Code for leaderboard_id for the specified year's event + Request the API JSON from Advent of Code for leaderboard_id for the specified year's event. If no year is input, year defaults to the current year """ @@ -580,9 +566,7 @@ class AocPrivateLeaderboard: @classmethod def from_json(cls, injson: dict) -> "AocPrivateLeaderboard": - """ - Generate an AocPrivateLeaderboard object from AoC's private leaderboard API JSON - """ + """Generate an AocPrivateLeaderboard object from AoC's private leaderboard API JSON.""" return cls( members=cls._sorted_members(injson["members"]), owner_id=injson["owner_id"], event_year=injson["event"] @@ -590,9 +574,7 @@ class AocPrivateLeaderboard: @classmethod async def from_url(cls) -> "AocPrivateLeaderboard": - """ - Helper wrapping of AocPrivateLeaderboard.json_from_url and AocPrivateLeaderboard.from_json - """ + """Helper wrapping of AocPrivateLeaderboard.json_from_url and AocPrivateLeaderboard.from_json.""" api_json = await cls.json_from_url() return cls.from_json(api_json) @@ -600,7 +582,7 @@ class AocPrivateLeaderboard: @staticmethod def _sorted_members(injson: dict) -> list: """ - Generate a sorted list of AocMember objects from AoC's private leaderboard API JSON + Generate a sorted list of AocMember objects from AoC's private leaderboard API JSON. Output list is sorted based on the AocMember.local_score """ @@ -613,7 +595,7 @@ class AocPrivateLeaderboard: @staticmethod def build_leaderboard_embed(members_to_print: List[AocMember]) -> str: """ - Build a text table from members_to_print, a list of AocMember objects + Build a text table from members_to_print, a list of AocMember objects. Returns a string to be used as the content of the bot's leaderboard response """ @@ -638,6 +620,8 @@ class AocPrivateLeaderboard: class AocGlobalLeaderboard: + """Object representing the Advent of Code global leaderboard.""" + def __init__(self, members: List[tuple]): self.members = members self.last_updated = datetime.utcnow() @@ -654,7 +638,7 @@ class AocGlobalLeaderboard: @classmethod async def from_url(cls) -> "AocGlobalLeaderboard": """ - Generate an list of tuples for the entries on AoC's global leaderboard + Generate an list of tuples for the entries on AoC's global leaderboard. Because there is no API for this, web scraping needs to be used """ @@ -700,7 +684,7 @@ class AocGlobalLeaderboard: @staticmethod def build_leaderboard_embed(members_to_print: List[tuple]) -> str: """ - Build a text table from members_to_print, a list of tuples + Build a text table from members_to_print, a list of tuples. Returns a string to be used as the content of the bot's leaderboard response """ @@ -721,13 +705,13 @@ class AocGlobalLeaderboard: def _error_embed_helper(title: str, description: str) -> discord.Embed: - """ - Return a red-colored Embed with the given title and description - """ + """Return a red-colored Embed with the given title and description.""" return discord.Embed(title=title, description=description, colour=discord.Colour.red()) def setup(bot: commands.Bot) -> None: + """Advent of Code Cog load.""" + bot.add_cog(AdventOfCode(bot)) log.info("Cog loaded: adventofcode") diff --git a/bot/seasons/evergreen/__init__.py b/bot/seasons/evergreen/__init__.py index db610e7c..ac32c199 100644 --- a/bot/seasons/evergreen/__init__.py +++ b/bot/seasons/evergreen/__init__.py @@ -2,4 +2,6 @@ from bot.seasons import SeasonBase class Evergreen(SeasonBase): + """Evergreen Seasonal event attributes.""" + bot_icon = "/logos/logo_seasonal/evergreen/logo_evergreen.png" diff --git a/bot/seasons/evergreen/error_handler.py b/bot/seasons/evergreen/error_handler.py index 47e18a31..dcdbe4e9 100644 --- a/bot/seasons/evergreen/error_handler.py +++ b/bot/seasons/evergreen/error_handler.py @@ -9,13 +9,13 @@ log = logging.getLogger(__name__) class CommandErrorHandler:
- """A error handler for the PythonDiscord server!"""
+ """A error handler for the PythonDiscord server."""
def __init__(self, bot):
self.bot = bot
async def on_command_error(self, ctx, error):
- """Activates when a command opens an error"""
+ """Activates when a command opens an error."""
if hasattr(ctx.command, 'on_error'):
return logging.debug(
@@ -108,5 +108,7 @@ class CommandErrorHandler: def setup(bot):
+ """Error handler Cog load."""
+
bot.add_cog(CommandErrorHandler(bot))
log.debug("CommandErrorHandler cog loaded")
diff --git a/bot/seasons/evergreen/fun.py b/bot/seasons/evergreen/fun.py index 4da01dd1..f5814a80 100644 --- a/bot/seasons/evergreen/fun.py +++ b/bot/seasons/evergreen/fun.py @@ -9,18 +9,15 @@ log = logging.getLogger(__name__) class Fun: - """ - A collection of general commands for fun. - """ + """A collection of general commands for fun.""" def __init__(self, bot): self.bot = bot @commands.command() async def roll(self, ctx, num_rolls: int = 1): - """ - Outputs a number of random dice emotes (up to 6) - """ + """Outputs a number of random dice emotes (up to 6).""" + output = "" if num_rolls > 6: num_rolls = 6 @@ -32,7 +29,8 @@ class Fun: await ctx.send(output) -# Required in order to load the cog, use the class name in the add_cog function. def setup(bot): + """Fun Cog load.""" + bot.add_cog(Fun(bot)) log.debug("Fun cog loaded") diff --git a/bot/seasons/evergreen/snakes/__init__.py b/bot/seasons/evergreen/snakes/__init__.py index 367aea4d..88793308 100644 --- a/bot/seasons/evergreen/snakes/__init__.py +++ b/bot/seasons/evergreen/snakes/__init__.py @@ -6,5 +6,7 @@ log = logging.getLogger(__name__) def setup(bot): + """Snakes Cog load.""" + bot.add_cog(Snakes(bot)) log.info("Cog loaded: Snakes") diff --git a/bot/seasons/evergreen/snakes/converter.py b/bot/seasons/evergreen/snakes/converter.py index c091d9c1..ec9c9870 100644 --- a/bot/seasons/evergreen/snakes/converter.py +++ b/bot/seasons/evergreen/snakes/converter.py @@ -13,10 +13,14 @@ log = logging.getLogger(__name__) class Snake(Converter): + """Snake converter for the Snakes Cog.""" + snakes = None special_cases = None async def convert(self, ctx, name): + """Convert the input snake name to the closest matching Snake object.""" + await self.build_list() name = name.lower() @@ -56,6 +60,8 @@ class Snake(Converter): @classmethod async def build_list(cls): + """Build list of snakes from the static snake resources.""" + # Get all the snakes if cls.snakes is None: with (SNAKE_RESOURCES / "snake_names.json").open() as snakefile: @@ -70,11 +76,14 @@ class Snake(Converter): @classmethod async def random(cls): """ - This is stupid. We should find a way to - somehow get the global session into a - global context, so I can get it from here. + Get a random Snake from the loaded resources. + + This is stupid. We should find a way to somehow get the global session into a global context, + so I can get it from here. + :return: """ + await cls.build_list() names = [snake['scientific'] for snake in cls.snakes] return random.choice(names) diff --git a/bot/seasons/evergreen/snakes/snakes_cog.py b/bot/seasons/evergreen/snakes/snakes_cog.py index 57eb7a52..dc5fb104 100644 --- a/bot/seasons/evergreen/snakes/snakes_cog.py +++ b/bot/seasons/evergreen/snakes/snakes_cog.py @@ -134,12 +134,11 @@ CARD = { class Snakes: """ - Commands related to snakes. These were created by our - community during the first code jam. + Commands related to snakes, created by our community during the first code jam. More information can be found in the code-jam-1 repo. - https://gitlab_bot_repo.com/discord-python/code-jams/code-jam-1 + https://github.com/python-discord/code-jam-1 """ wiki_brief = re.compile(r'(.*?)(=+ (.*?) =+)', flags=re.DOTALL) @@ -156,9 +155,8 @@ class Snakes: # region: Helper methods @staticmethod def _beautiful_pastel(hue): - """ - Returns random bright pastels. - """ + """Returns random bright pastels.""" + light = random.uniform(0.7, 0.85) saturation = 1 @@ -178,6 +176,7 @@ class Snakes: Written by juan and Someone during the first code jam. """ + snake = Image.open(buffer) # Get the size of the snake icon, configure the height of the image box (yes, it changes) @@ -254,9 +253,8 @@ class Snakes: @staticmethod def _snakify(message): - """ - Sssnakifffiesss a sstring. - """ + """Sssnakifffiesss a sstring.""" + # Replace fricatives with exaggerated snake fricatives. simple_fricatives = [ "f", "s", "z", "h", @@ -278,9 +276,8 @@ class Snakes: return message async def _fetch(self, session, url, params=None): - """ - Asyncronous web request helper method. - """ + """Asynchronous web request helper method.""" + if params is None: params = {} @@ -290,11 +287,11 @@ class Snakes: def _get_random_long_message(self, messages, retries=10): """ - Fetch a message that's at least 3 words long, - but only if it is possible to do so in retries - attempts. Else, just return whatever the last - message is. + Fetch a message that's at least 3 words long, if possible to do so in retries attempts. + + Else, just return whatever the last message is. """ + long_message = random.choice(messages) if len(long_message.split()) < 3 and retries > 0: return self._get_random_long_message( @@ -306,14 +303,16 @@ class Snakes: async def _get_snek(self, name: str) -> Dict[str, Any]: """ - Goes online and fetches all the data from a wikipedia article - about a snake. Builds a dict that the .get() method can use. + Fetches all the data from a wikipedia article about a snake. + + Builds a dict that the .get() method can use. Created by Ava and eivl. :param name: The name of the snake to get information for - omit for a random snake :return: A dict containing information on a snake """ + snake_info = {} async with aiohttp.ClientSession() as session: @@ -412,20 +411,21 @@ class Snakes: async def _get_snake_name(self) -> Dict[str, str]: """ Gets a random snake name. + :return: A random snake name, as a string. """ return random.choice(self.snake_names) async def _validate_answer(self, ctx: Context, message: Message, answer: str, options: list): """ - Validate the answer using a reaction event loop + Validate the answer using a reaction event loop. + :return: """ def predicate(reaction, user): - """ - Test if the the answer is valid and can be evaluated. - """ + """Test if the the answer is valid and can be evaluated.""" + return ( reaction.message.id == message.id # The reaction is attached to the question we asked. and user == ctx.author # It's the user who triggered the quiz. @@ -465,7 +465,7 @@ class Snakes: @locked() async def antidote_command(self, ctx: Context): """ - Antidote - Can you create the antivenom before the patient dies? + Antidote! Can you create the antivenom before the patient dies? Rules: You have 4 ingredients for each antidote, you only have 10 attempts Once you synthesize the antidote, you will be presented with 4 markers @@ -480,9 +480,7 @@ class Snakes: """ def predicate(reaction_: Reaction, user_: Member): - """ - Make sure that this reaction is what we want to operate on - """ + """Make sure that this reaction is what we want to operate on.""" return ( all(( @@ -610,7 +608,7 @@ class Snakes: @snakes_group.command(name='draw') async def draw_command(self, ctx: Context): """ - Draws a random snek using Perlin noise + Draws a random snek using Perlin noise. Written by Momo and kel. Modified by juan and lemon. @@ -652,12 +650,14 @@ class Snakes: async def get_command(self, ctx: Context, *, name: Snake = None): """ Fetches information about a snake from Wikipedia. + :param ctx: Context object passed from discord.py :param name: Optional, the name of the snake to get information for - omit for a random snake Created by Ava and eivl. """ + with ctx.typing(): if name is None: name = await Snake.random() @@ -702,11 +702,12 @@ class Snakes: @locked() async def guess_command(self, ctx): """ - Snake identifying game! + Snake identifying game. Made by Ava and eivl. Modified by lemon. """ + with ctx.typing(): image = None @@ -736,10 +737,11 @@ class Snakes: @snakes_group.command(name='hatch') async def hatch_command(self, ctx: Context): """ - Hatches your personal snake + Hatches your personal snake. Written by Momo and kel. """ + # Pick a random snake to hatch. snake_name = random.choice(list(utils.snakes.keys())) snake_image = utils.snakes[snake_name] @@ -772,6 +774,7 @@ class Snakes: Written by Samuel. Modified by gdude. """ + url = "http://www.omdbapi.com/" page = random.randint(1, 27) @@ -842,6 +845,7 @@ class Snakes: This was created by Mushy and Cardium, and modified by Urthas and lemon. """ + # Prepare a question. question = random.choice(self.snake_quizzes) answer = question["answerkey"] @@ -862,6 +866,8 @@ class Snakes: @snakes_group.command(name='name', aliases=('name_gen',)) async def name_command(self, ctx: Context, *, name: str = None): """ + Snakifies a username. + Slices the users name at the last vowel (or second last if the name ends with a vowel), and then combines it with a random snake name, which is sliced at the first vowel (or second if the name starts with @@ -880,6 +886,7 @@ class Snakes: This was written by Iceman, and modified for inclusion into the bot by lemon. """ + snake_name = await self._get_snake_name() snake_name = snake_name['name'] snake_prefix = "" @@ -932,11 +939,12 @@ class Snakes: @locked() async def sal_command(self, ctx: Context): """ - Play a game of Snakes and Ladders! + Play a game of Snakes and Ladders. Written by Momo and kel. Modified by lemon. """ + # check if there is already a game in this channel if ctx.channel in self.active_sal: await ctx.send(f"{ctx.author.mention} A game is already in progress in this channel.") @@ -949,10 +957,8 @@ class Snakes: @snakes_group.command(name='about') async def about_command(self, ctx: Context): - """ - A command that shows an embed with information about the event, - it's participants, and its winners. - """ + """Show an embed with information about the event, its participants, and its winners.""" + contributors = [ "<@!245270749919576066>", "<@!396290259907903491>", @@ -996,10 +1002,11 @@ class Snakes: @snakes_group.command(name='card') async def card_command(self, ctx: Context, *, name: Snake = None): """ - Create an interesting little card from a snake! + Create an interesting little card from a snake. Created by juan and Someone during the first code jam. """ + # Get the snake data we need if not name: name_obj = await self._get_snake_name() @@ -1034,11 +1041,12 @@ class Snakes: @snakes_group.command(name='fact') async def fact_command(self, ctx: Context): """ - Gets a snake-related fact + Gets a snake-related fact. Written by Andrew and Prithaj. Modified by lemon. """ + question = random.choice(self.snake_facts)["fact"] embed = Embed( title="Snake fact", @@ -1049,9 +1057,8 @@ class Snakes: @snakes_group.command(name='help') async def help_command(self, ctx: Context): - """ - This just invokes the help command on this cog. - """ + """Invokes the help command for the Snakes Cog.""" + log.debug(f"{ctx.author} requested info about the snakes cog") return await ctx.invoke(self.bot.get_command("help"), "Snakes") @@ -1059,6 +1066,7 @@ class Snakes: async def snakify_command(self, ctx: Context, *, message: str = None): """ How would I talk if I were a snake? + :param ctx: context :param message: If this is passed, it will snakify the message. If not, it will snakify a random message from @@ -1067,6 +1075,7 @@ class Snakes: Written by Momo and kel. Modified by lemon. """ + with ctx.typing(): embed = Embed() user = ctx.message.author @@ -1100,13 +1109,14 @@ class Snakes: @snakes_group.command(name='video', aliases=('get_video',)) async def video_command(self, ctx: Context, *, search: str = None): """ - Gets a YouTube video about snakes + Gets a YouTube video about snakes. :param ctx: Context object passed from discord.py :param search: Optional, a name of a snake. Used to search for videos with that name Written by Andrew and Prithaj. """ + # Are we searching for anything specific? if search: query = search + ' snake' @@ -1141,12 +1151,12 @@ class Snakes: @snakes_group.command(name='zen') async def zen_command(self, ctx: Context): """ - Gets a random quote from the Zen of Python, - except as if spoken by a snake. + Gets a random quote from the Zen of Python, except as if spoken by a snake. Written by Prithaj and Andrew. Modified by lemon. """ + embed = Embed( title="Zzzen of Pythhon", color=SNAKE_COLOR @@ -1168,6 +1178,7 @@ class Snakes: @card_command.error @video_command.error async def command_error(self, ctx, error): + """Local error handler for the Snake Cog.""" embed = Embed() embed.colour = Colour.red() @@ -1190,5 +1201,7 @@ class Snakes: def setup(bot): + """Snake Cog load.""" + bot.add_cog(Snakes(bot)) log.info("Cog loaded: Snakes") diff --git a/bot/seasons/evergreen/snakes/utils.py b/bot/seasons/evergreen/snakes/utils.py index ec280223..e2ed60bd 100644 --- a/bot/seasons/evergreen/snakes/utils.py +++ b/bot/seasons/evergreen/snakes/utils.py @@ -1,8 +1,3 @@ -""" -Perlin noise implementation. -Taken from: https://gist.github.com/eevee/26f547457522755cb1fb8739d0ea89a1 -Licensed under ISC -""" import asyncio import io import json @@ -117,43 +112,54 @@ ANGLE_RANGE = math.pi * 2 def get_resource(file: str) -> List[dict]: + """Load Snake resources JSON.""" + with (SNAKE_RESOURCES / f"{file}.json").open(encoding="utf-8") as snakefile: return json.load(snakefile) def smoothstep(t): - """Smooth curve with a zero derivative at 0 and 1, making it useful for - interpolating. - """ + """Smooth curve with a zero derivative at 0 and 1, making it useful for interpolating.""" + return t * t * (3. - 2. * t) def lerp(t, a, b): """Linear interpolation between a and b, given a fraction t.""" + return a + t * (b - a) class PerlinNoiseFactory(object): - """Callable that produces Perlin noise for an arbitrary point in an - arbitrary number of dimensions. The underlying grid is aligned with the - integers. - There is no limit to the coordinates used; new gradients are generated on - the fly as necessary. + """ + Callable that produces Perlin noise for an arbitrary point in an arbitrary number of dimensions. + + The underlying grid is aligned with the integers. + + There is no limit to the coordinates used; new gradients are generated on the fly as necessary. + + Taken from: https://gist.github.com/eevee/26f547457522755cb1fb8739d0ea89a1 + Licensed under ISC """ def __init__(self, dimension, octaves=1, tile=(), unbias=False): - """Create a new Perlin noise factory in the given number of dimensions, - which should be an integer and at least 1. - More octaves create a foggier and more-detailed noise pattern. More - than 4 octaves is rather excessive. - ``tile`` can be used to make a seamlessly tiling pattern. For example: + """ + Create a new Perlin noise factory in the given number of dimensions. + + dimension should be an integer and at least 1. + + More octaves create a foggier and more-detailed noise pattern. More than 4 octaves is rather excessive. + + ``tile`` can be used to make a seamlessly tiling pattern. + For example: pnf = PerlinNoiseFactory(2, tile=(0, 3)) - This will produce noise that tiles every 3 units vertically, but never - tiles horizontally. - If ``unbias`` is true, the smoothstep function will be applied to the - output before returning it, to counteract some of Perlin noise's - significant bias towards the center of its output range. + + This will produce noise that tiles every 3 units vertically, but never tiles horizontally. + + If ``unbias`` is true, the smoothstep function will be applied to the output before returning + it, to counteract some of Perlin noise's significant bias towards the center of its output range. """ + self.dimension = dimension self.octaves = octaves self.tile = tile + (0,) * dimension @@ -166,8 +172,11 @@ class PerlinNoiseFactory(object): self.gradient = {} def _generate_gradient(self): - # Generate a random unit vector at each grid point -- this is the - # "gradient" vector, in that the grid tile slopes towards it + """ + Generate a random unit vector at each grid point. + + This is the "gradient" vector, in that the grid tile slopes towards it + """ # 1 dimension is special, since the only unit vector is trivial; # instead, use a slope between -1 and 1 @@ -184,9 +193,8 @@ class PerlinNoiseFactory(object): return tuple(coord * scale for coord in random_point) def get_plain_noise(self, *point): - """Get plain noise for a single point, without taking into account - either octaves or tiling. - """ + """Get plain noise for a single point, without taking into account either octaves or tiling.""" + if len(point) != self.dimension: raise ValueError("Expected {0} values, got {1}".format( self.dimension, len(point))) @@ -234,9 +242,12 @@ class PerlinNoiseFactory(object): return dots[0] * self.scale_factor def __call__(self, *point): - """Get the value of this Perlin noise function at the given point. The - number of values given should match the number of dimensions. """ + Get the value of this Perlin noise function at the given point. + + The number of values given should match the number of dimensions. + """ + ret = 0 for o in range(self.octaves): o2 = 1 << o @@ -281,6 +292,7 @@ def create_snek_frame( ) -> Image: """ Creates a single random snek frame using Perlin noise. + :param perlin_factory: the perlin noise factory used. Required. :param perlin_lookup_vertical_shift: the Perlin noise shift in the Y-dimension for this frame :param image_dimensions: the size of the output image. @@ -288,14 +300,15 @@ def create_snek_frame( :param snake_length: the length of the snake, in segments. :param snake_color: the color of the snake. :param bg_color: the background color. - :param segment_length_range: the range of the segment length. Values will be generated inside this range, including - the bounds. + :param segment_length_range: the range of the segment length. Values will be generated inside + this range, including the bounds. :param snake_width: the width of the snek, in pixels. :param text: the text to display with the snek. Set to None for no text. :param text_position: the position of the text. :param text_color: the color of the text. :return: a PIL image, representing a single frame. """ + start_x = random.randint(image_margins[X], image_dimensions[X] - image_margins[X]) start_y = random.randint(image_margins[Y], image_dimensions[Y] - image_margins[Y]) points = [(start_x, start_y)] @@ -349,6 +362,8 @@ def create_snek_frame( def frame_to_png_bytes(image: Image): + """Convert image to byte stream.""" + stream = io.BytesIO() image.save(stream, format='PNG') return stream.getvalue() @@ -371,6 +386,8 @@ GAME_SCREEN_EMOJI = [ class SnakeAndLaddersGame: + """Snakes and Ladders game Cog.""" + def __init__(self, snakes, context: Context): self.snakes = snakes self.ctx = context @@ -393,10 +410,10 @@ class SnakeAndLaddersGame: Listen for reactions until players have joined, and the game has been started. """ + def startup_event_check(reaction_: Reaction, user_: Member): - """ - Make sure that this reaction is what we want to operate on - """ + """Make sure that this reaction is what we want to operate on.""" + return ( all(( reaction_.message.id == startup.id, # Reaction is on startup message @@ -471,6 +488,13 @@ class SnakeAndLaddersGame: self.avatar_images[user.id] = im async def player_join(self, user: Member): + """ + Handle players joining the game. + + Prevent player joining if they have already joined, if the game is full, or if the game is + in a waiting state. + """ + for p in self.players: if user == p: await self.channel.send(user.mention + " You are already in the game.", delete_after=10) @@ -491,6 +515,13 @@ class SnakeAndLaddersGame: ) async def player_leave(self, user: Member): + """ + Handle players leaving the game. + + Leaving is prevented if the user initiated the game or if they weren't part of it in the + first place. + """ + if user == self.author: await self.channel.send( user.mention + " You are the author, and cannot leave the game. Execute " @@ -515,6 +546,8 @@ class SnakeAndLaddersGame: await self.channel.send(user.mention + " You are not in the match.", delete_after=10) async def cancel_game(self, user: Member): + """Allow the game author to cancel the running game.""" + if not user == self.author: await self.channel.send(user.mention + " Only the author of the game can cancel it.", delete_after=10) return @@ -522,6 +555,13 @@ class SnakeAndLaddersGame: self._destruct() async def start_game(self, user: Member): + """ + Allow the game author to begin the game. + + The game cannot be started if there aren't enough players joined or if the game is in a + waiting state. + """ + if not user == self.author: await self.channel.send(user.mention + " Only the author of the game can start it.", delete_after=10) return @@ -540,10 +580,11 @@ class SnakeAndLaddersGame: await self.start_round() async def start_round(self): + """Begin the round.""" + def game_event_check(reaction_: Reaction, user_: Member): - """ - Make sure that this reaction is what we want to operate on - """ + """Make sure that this reaction is what we want to operate on.""" + return ( all(( reaction_.message.id == self.positions.id, # Reaction is on positions message @@ -634,6 +675,8 @@ class SnakeAndLaddersGame: await self._complete_round() async def player_roll(self, user: Member): + """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) return diff --git a/bot/seasons/evergreen/uptime.py b/bot/seasons/evergreen/uptime.py index 1321da19..d6b59a69 100644 --- a/bot/seasons/evergreen/uptime.py +++ b/bot/seasons/evergreen/uptime.py @@ -10,18 +10,15 @@ log = logging.getLogger(__name__) class Uptime: - """ - A cog for posting the bots uptime. - """ + """A cog for posting the bot's uptime.""" def __init__(self, bot): self.bot = bot @commands.command(name="uptime") async def uptime(self, ctx): - """ - Returns the uptime of the bot. - """ + """Responds with the uptime of the bot.""" + difference = relativedelta(start_time - arrow.utcnow()) uptime_string = start_time.shift( seconds=-difference.seconds, @@ -32,7 +29,8 @@ class Uptime: await ctx.send(f"I started up {uptime_string}.") -# Required in order to load the cog, use the class name in the add_cog function. def setup(bot): + """Uptime Cog load.""" + bot.add_cog(Uptime(bot)) log.debug("Uptime cog loaded") diff --git a/bot/seasons/halloween/__init__.py b/bot/seasons/halloween/__init__.py index 4b371f14..74c962ed 100644 --- a/bot/seasons/halloween/__init__.py +++ b/bot/seasons/halloween/__init__.py @@ -3,6 +3,8 @@ from bot.seasons import SeasonBase class Halloween(SeasonBase): + """Halloween Seasonal event attributes.""" + name = "halloween" bot_name = "Spookybot" greeting = "Happy Halloween!" diff --git a/bot/seasons/halloween/candy_collection.py b/bot/seasons/halloween/candy_collection.py index 80f30a1b..1bfbc00a 100644 --- a/bot/seasons/halloween/candy_collection.py +++ b/bot/seasons/halloween/candy_collection.py @@ -21,6 +21,8 @@ ADD_SKULL_EXISTING_REACTION_CHANCE = 20 # 5% class CandyCollection: + """Candy collection game Cog.""" + def __init__(self, bot): self.bot = bot with open(json_location) as candy: @@ -32,9 +34,7 @@ class CandyCollection: self.get_candyinfo[userid] = userinfo async def on_message(self, message): - """ - Randomly adds candy or skull to certain messages - """ + """Randomly adds candy or skull reaction to non-bot messages in the Event channel.""" # make sure its a human message if message.author.bot: @@ -55,9 +55,7 @@ class CandyCollection: return await message.add_reaction('\N{CANDY}') async def on_reaction_add(self, reaction, user): - """ - Add/remove candies from a person if the reaction satisfies criteria - """ + """Add/remove candies from a person if the reaction satisfies criteria.""" message = reaction.message # check to ensure the reactor is human @@ -105,8 +103,10 @@ class CandyCollection: async def reacted_msg_chance(self, message): """ - Randomly add a skull or candy to a message if there is a reaction there already - (higher probability) + Randomly add a skull or candy reaction to a message if there is a reaction there already. + + This event has a higher probability of occurring than a reaction add to a message without an + existing reaction. """ if random.randint(1, ADD_SKULL_EXISTING_REACTION_CHANCE) == 1: @@ -120,7 +120,8 @@ class CandyCollection: return await message.add_reaction('\N{CANDY}') async def ten_recent_msg(self): - """Get the last 10 messages sent in the channel""" + """Get the last 10 messages sent in the channel.""" + ten_recent = [] recent_msg = max(message.id for message in self.bot._connection._messages @@ -137,9 +138,7 @@ class CandyCollection: return ten_recent async def get_message(self, msg_id): - """ - Get the message from it's ID. - """ + """Get the message from its ID.""" try: o = discord.Object(id=msg_id + 1) @@ -156,15 +155,12 @@ class CandyCollection: return None async def hacktober_channel(self): - """ - Get #hacktoberbot channel from it's id - """ + """Get #hacktoberbot channel from its ID.""" + return self.bot.get_channel(id=Hacktoberfest.channel_id) async def remove_reactions(self, reaction): - """ - Remove all candy/skull reactions - """ + """Remove all candy/skull reactions.""" try: async for user in reaction.users(): @@ -174,26 +170,22 @@ class CandyCollection: pass async def send_spook_msg(self, author, channel, candies): - """ - Send a spooky message - """ + """Send a spooky message.""" + e = discord.Embed(colour=author.colour) e.set_author(name="Ghosts and Ghouls and Jack o' lanterns at night; " f"I took {candies} candies and quickly took flight.") await channel.send(embed=e) def save_to_json(self): - """ - Save json to the file. - """ + """Save JSON to a local file.""" + with open(json_location, 'w') as outfile: json.dump(self.candy_json, outfile) @commands.command() async def candy(self, ctx): - """ - Get the candy leaderboard and save to json when this is called - """ + """Get the candy leaderboard and save to JSON.""" # use run_in_executor to prevent blocking thing = functools.partial(self.save_to_json) @@ -230,5 +222,7 @@ class CandyCollection: def setup(bot): + """Candy Collection game Cog load.""" + bot.add_cog(CandyCollection(bot)) log.debug("CandyCollection cog loaded") diff --git a/bot/seasons/halloween/hacktoberstats.py b/bot/seasons/halloween/hacktoberstats.py index 41cf10ee..3e2a261d 100644 --- a/bot/seasons/halloween/hacktoberstats.py +++ b/bot/seasons/halloween/hacktoberstats.py @@ -14,6 +14,8 @@ log = logging.getLogger(__name__) class HacktoberStats: + """Hacktoberfest statistics Cog.""" + def __init__(self, bot): self.bot = bot self.link_json = Path("bot", "resources", "github_links.json") @@ -26,11 +28,13 @@ class HacktoberStats: ) async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None): """ - If invoked without a subcommand or github_username, get the invoking user's stats if - they've linked their Discord name to GitHub using .stats link + Display an embed for a user's Hacktoberfest contributions. - If invoked with a github_username, get that user's contributions + If invoked without a subcommand or github_username, get the invoking user's stats if they've + linked their Discord name to GitHub using .stats link. If invoked with a github_username, + get that user's contributions """ + if not github_username: author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) @@ -51,7 +55,7 @@ class HacktoberStats: @hacktoberstats_group.command(name="link") async def link_user(self, ctx: commands.Context, github_username: str = None): """ - Link the invoking user's Github github_username to their Discord ID + Link the invoking user's Github github_username to their Discord ID. Linked users are stored as a nested dict: { @@ -61,6 +65,7 @@ class HacktoberStats: } } """ + author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) if github_username: if str(author_id) in self.linked_accounts.keys(): @@ -83,9 +88,8 @@ class HacktoberStats: @hacktoberstats_group.command(name="unlink") async def unlink_user(self, ctx: commands.Context): - """ - Remove the invoking user's account link from the log - """ + """Remove the invoking user's account link from the log.""" + author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) stored_user = self.linked_accounts.pop(author_id, None) @@ -100,7 +104,7 @@ class HacktoberStats: def load_linked_users(self) -> typing.Dict: """ - Load list of linked users from local JSON file + Load list of linked users from local JSON file. Linked users are stored as a nested dict: { @@ -110,6 +114,7 @@ class HacktoberStats: } } """ + if self.link_json.exists(): logging.info(f"Loading linked GitHub accounts from '{self.link_json}'") with open(self.link_json, 'r') as fID: @@ -123,7 +128,7 @@ class HacktoberStats: def save_linked_users(self): """ - Save list of linked users to local JSON file + Save list of linked users to local JSON file. Linked users are stored as a nested dict: { @@ -133,6 +138,7 @@ class HacktoberStats: } } """ + logging.info(f"Saving linked_accounts to '{self.link_json}'") with open(self.link_json, 'w') as fID: json.dump(self.linked_accounts, fID, default=str) @@ -140,16 +146,15 @@ class HacktoberStats: async def get_stats(self, ctx: commands.Context, github_username: str): """ - Query GitHub's API for PRs created by a GitHub user during the month of October that - do not have an 'invalid' tag + Query GitHub's API for PRs created by a GitHub user during the month of October. - For example: - !getstats heavysaturn + PRs with the 'invalid' tag are ignored If a valid github_username is provided, an embed is generated and posted to the channel Otherwise, post a helpful error message """ + async with ctx.typing(): prs = await self.get_october_prs(github_username) @@ -160,9 +165,8 @@ class HacktoberStats: await ctx.send(f"No October GitHub contributions found for '{github_username}'") def build_embed(self, github_username: str, prs: typing.List[dict]) -> discord.Embed: - """ - Return a stats embed built from github_username's PRs - """ + """Return a stats embed built from github_username's PRs.""" + logging.info(f"Building Hacktoberfest embed for GitHub user: '{github_username}'") pr_stats = self._summarize_prs(prs) @@ -202,8 +206,9 @@ class HacktoberStats: @staticmethod async def get_october_prs(github_username: str) -> typing.List[dict]: """ - Query GitHub's API for PRs created during the month of October by github_username - that do not have an 'invalid' tag + Query GitHub's API for PRs created during the month of October by github_username. + + PRs with an 'invalid' tag are ignored If PRs are found, return a list of dicts with basic PR information @@ -216,6 +221,7 @@ class HacktoberStats: Otherwise, return None """ + logging.info(f"Generating Hacktoberfest PR query for GitHub user: '{github_username}'") base_url = "https://api.github.com/search/issues?q=" not_label = "invalid" @@ -265,20 +271,21 @@ class HacktoberStats: @staticmethod def _get_shortname(in_url: str) -> str: """ - Extract shortname from https://api.github.com/repos/* URL + Extract shortname from https://api.github.com/repos/* URL. e.g. "https://api.github.com/repos/python-discord/seasonalbot" | V "python-discord/seasonalbot" """ + exp = r"https?:\/\/api.github.com\/repos\/([/\-\_\.\w]+)" return re.findall(exp, in_url)[0] @staticmethod def _summarize_prs(prs: typing.List[dict]) -> typing.Dict: """ - Generate statistics from an input list of PR dictionaries, as output by get_october_prs + Generate statistics from an input list of PR dictionaries, as output by get_october_prs. Return a dictionary containing: { @@ -286,13 +293,14 @@ class HacktoberStats: "top5": [(repo_shortname, ncontributions), ...] } """ + contributed_repos = [pr["repo_shortname"] for pr in prs] return {"n_prs": len(prs), "top5": Counter(contributed_repos).most_common(5)} @staticmethod def _build_top5str(stats: typing.List[tuple]) -> str: """ - Build a string from the Top 5 contributions that is compatible with a discord.Embed field + Build a string from the Top 5 contributions that is compatible with a discord.Embed field. Top 5 contributions should be a list of tuples, as output in the stats dictionary by _summarize_prs @@ -301,6 +309,7 @@ class HacktoberStats: n contribution(s) to [shortname](url) ... """ + baseURL = "https://www.github.com/" contributionstrs = [] for repo in stats['top5']: @@ -311,9 +320,8 @@ class HacktoberStats: @staticmethod def _contributionator(n: int) -> str: - """ - Return "contribution" or "contributions" based on the value of n - """ + """Return "contribution" or "contributions" based on the value of n.""" + if n == 1: return "contribution" else: @@ -321,9 +329,8 @@ class HacktoberStats: @staticmethod def _author_mention_from_context(ctx: commands.Context) -> typing.Tuple: - """ - Return stringified Message author ID and mentionable string from commands.Context - """ + """Return stringified Message author ID and mentionable string from commands.Context.""" + author_id = str(ctx.message.author.id) author_mention = ctx.message.author.mention @@ -331,5 +338,7 @@ class HacktoberStats: def setup(bot): + """Hacktoberstats Cog load.""" + bot.add_cog(HacktoberStats(bot)) log.debug("HacktoberStats cog loaded") diff --git a/bot/seasons/halloween/halloween_facts.py b/bot/seasons/halloween/halloween_facts.py index 098ee432..21c70e76 100644 --- a/bot/seasons/halloween/halloween_facts.py +++ b/bot/seasons/halloween/halloween_facts.py @@ -26,6 +26,7 @@ INTERVAL = timedelta(hours=6).total_seconds() class HalloweenFacts: + """A Cog for displaying interesting facts about Halloween.""" def __init__(self, bot): self.bot = bot @@ -36,31 +37,35 @@ class HalloweenFacts: random.shuffle(self.facts) async def on_ready(self): + """Get event Channel object and initialize fact task loop.""" + self.channel = self.bot.get_channel(Hacktoberfest.channel_id) self.bot.loop.create_task(self._fact_publisher_task()) def random_fact(self): + """Return a random fact from the loaded facts.""" + return random.choice(self.facts) @commands.command(name="spookyfact", aliases=("halloweenfact",), brief="Get the most recent Halloween fact") async def get_random_fact(self, ctx): - """ - Reply with the most recent Halloween fact. - """ + """Reply with the most recent Halloween fact.""" + index, fact = self.random_fact() embed = self._build_embed(index, fact) await ctx.send(embed=embed) @staticmethod def _build_embed(index, fact): - """ - Builds a Discord embed from the given fact and its index. - """ + """Builds a Discord embed from the given fact and its index.""" + emoji = random.choice(SPOOKY_EMOJIS) title = f"{emoji} Halloween Fact #{index + 1}" return discord.Embed(title=title, description=fact, color=PUMPKIN_ORANGE) def setup(bot): + """Halloween facts Cog load.""" + bot.add_cog(HalloweenFacts(bot)) log.debug("HalloweenFacts cog loaded") diff --git a/bot/seasons/halloween/halloweenify.py b/bot/seasons/halloween/halloweenify.py index cda07472..5b710f7f 100644 --- a/bot/seasons/halloween/halloweenify.py +++ b/bot/seasons/halloween/halloweenify.py @@ -11,9 +11,7 @@ log = logging.getLogger(__name__) class Halloweenify: - """ - A cog to change a invokers nickname to a spooky one! - """ + """A cog to change a invokers nickname to a spooky one.""" def __init__(self, bot): self.bot = bot @@ -21,9 +19,8 @@ class Halloweenify: @commands.cooldown(1, 300, BucketType.user) @commands.command() async def halloweenify(self, ctx): - """ - Change your nickname into a much spookier one! - """ + """Change your nickname into a much spookier one.""" + async with ctx.typing(): with open(Path('bot', 'resources', 'halloween', 'halloweenify.json'), 'r') as f: data = load(f) @@ -51,5 +48,7 @@ class Halloweenify: def setup(bot): + """Halloweenify Cog load.""" + bot.add_cog(Halloweenify(bot)) log.debug("Halloweenify cog loaded") diff --git a/bot/seasons/halloween/monstersurvey.py b/bot/seasons/halloween/monstersurvey.py index 08873f24..44400a72 100644 --- a/bot/seasons/halloween/monstersurvey.py +++ b/bot/seasons/halloween/monstersurvey.py @@ -16,8 +16,10 @@ EMOJIS = { class MonsterSurvey: """ - Vote for your favorite monster! - This command allows users to vote for their favorite listed monster. + Vote for your favorite monster. + + This Cog allows users to vote for their favorite listed monster. + Users may change their vote, but only their current vote will be counted. """ @@ -30,12 +32,18 @@ class MonsterSurvey: self.voter_registry = json.load(jason) def json_write(self): + """Write voting results to a local JSON file.""" + log.info("Saved Monster Survey Results") with open(self.registry_location, 'w') as jason: json.dump(self.voter_registry, jason, indent=2) def cast_vote(self, id: int, monster: str): """ + Cast a user's vote for the specified monster. + + If the user has already voted, their existing vote is removed. + :param id: The id of the person voting :param monster: the string key of the json that represents a monster :return: None @@ -50,6 +58,8 @@ class MonsterSurvey: vr[m]['votes'].remove(id) def get_name_by_leaderboard_index(self, n): + """Return the monster at the specified leaderboard index.""" + n = n - 1 vr = self.voter_registry top = sorted(vr, key=lambda k: len(vr[k]['votes']), reverse=True) @@ -61,9 +71,7 @@ class MonsterSurvey: aliases=('ms',) ) async def monster_group(self, ctx: Context): - """ - The base voting command. If nothing is called, then it will return an embed. - """ + """The base voting command. If nothing is called, then it will return an embed.""" if ctx.invoked_subcommand is None: async with ctx.typing(): @@ -95,8 +103,9 @@ class MonsterSurvey: ) async def monster_vote(self, ctx: Context, name=None): """ - Casts a vote for a particular monster, or displays a list of monsters that can be voted for - if one is not given. + Cast a vote for a particular monster. + + Displays a list of monsters that can be voted for if one is not specified. """ if name is None: @@ -185,6 +194,7 @@ class MonsterSurvey: async def monster_leaderboard(self, ctx: Context): """ Shows the current standings. + :param ctx: :return: """ @@ -214,5 +224,7 @@ class MonsterSurvey: def setup(bot): + """Monster survey Cog load.""" + bot.add_cog(MonsterSurvey(bot)) log.debug("MonsterSurvey cog loaded") diff --git a/bot/seasons/halloween/scarymovie.py b/bot/seasons/halloween/scarymovie.py index b280781e..9108c76f 100644 --- a/bot/seasons/halloween/scarymovie.py +++ b/bot/seasons/halloween/scarymovie.py @@ -14,18 +14,15 @@ TMDB_TOKEN = environ.get('TMDB_TOKEN') class ScaryMovie: - """ - Selects a random scary movie and embeds info into discord chat - """ + """Selects a random scary movie and embeds info into Discord chat.""" def __init__(self, bot): self.bot = bot @commands.command(name='scarymovie', alias=['smovie']) async def random_movie(self, ctx): - """ - Randomly select a scary movie and display information about it. - """ + """Randomly select a scary movie and display information about it.""" + async with ctx.typing(): selection = await self.select_movie() movie_details = await self.format_metadata(selection) @@ -34,9 +31,7 @@ class ScaryMovie: @staticmethod async def select_movie(): - """ - Selects a random movie and returns a json of movie details from TMDb - """ + """Selects a random movie and returns a json of movie details from TMDb.""" url = 'https://api.themoviedb.org/4/discover/movie' params = { @@ -70,9 +65,7 @@ class ScaryMovie: @staticmethod async def format_metadata(movie): - """ - Formats raw TMDb data to be embedded in discord chat - """ + """Formats raw TMDb data to be embedded in discord chat.""" # Build the relevant URLs. movie_id = movie.get("id") @@ -137,5 +130,7 @@ class ScaryMovie: def setup(bot): + """Scary movie Cog load.""" + bot.add_cog(ScaryMovie(bot)) log.debug("ScaryMovie cog loaded") diff --git a/bot/seasons/halloween/spookyavatar.py b/bot/seasons/halloween/spookyavatar.py index a1173740..5c13ad77 100644 --- a/bot/seasons/halloween/spookyavatar.py +++ b/bot/seasons/halloween/spookyavatar.py @@ -13,18 +13,14 @@ log = logging.getLogger(__name__) class SpookyAvatar: - - """ - A cog that spookifies an avatar. - """ + """A cog that spookifies an avatar.""" def __init__(self, bot): self.bot = bot async def get(self, url): - """ - Returns the contents of the supplied url. - """ + """Returns the contents of the supplied url.""" + async with aiohttp.ClientSession() as session: async with session.get(url) as resp: return await resp.read() @@ -32,9 +28,8 @@ class SpookyAvatar: @commands.command(name='savatar', aliases=('spookyavatar', 'spookify'), brief='Spookify an user\'s avatar.') async def spooky_avatar(self, ctx, user: discord.Member = None): - """ - A command to print the user's spookified avatar. - """ + """A command to print the user's spookified avatar.""" + if user is None: user = ctx.message.author @@ -54,5 +49,7 @@ class SpookyAvatar: def setup(bot): + """Spooky avatar Cog load.""" + bot.add_cog(SpookyAvatar(bot)) log.debug("SpookyAvatar cog loaded") diff --git a/bot/seasons/halloween/spookygif.py b/bot/seasons/halloween/spookygif.py index 1233773b..f898d28b 100644 --- a/bot/seasons/halloween/spookygif.py +++ b/bot/seasons/halloween/spookygif.py @@ -10,18 +10,14 @@ log = logging.getLogger(__name__) class SpookyGif: - """ - A cog to fetch a random spooky gif from the web! - """ + """A cog to fetch a random spooky gif from the web.""" def __init__(self, bot): self.bot = bot @commands.command(name="spookygif", aliases=("sgif", "scarygif")) async def spookygif(self, ctx): - """ - Fetches a random gif from the GIPHY API and responds with it. - """ + """Fetches a random gif from the GIPHY API and responds with it.""" async with ctx.typing(): async with aiohttp.ClientSession() as session: @@ -39,5 +35,7 @@ class SpookyGif: def setup(bot): + """Spooky GIF Cog load.""" + bot.add_cog(SpookyGif(bot)) log.debug("SpookyGif cog loaded") diff --git a/bot/seasons/halloween/spookyreact.py b/bot/seasons/halloween/spookyreact.py index f63cd7e5..553bd285 100644 --- a/bot/seasons/halloween/spookyreact.py +++ b/bot/seasons/halloween/spookyreact.py @@ -17,22 +17,20 @@ SPOOKY_TRIGGERS = { class SpookyReact: - - """ - A cog that makes the bot react to message triggers. - """ + """A cog that makes the bot react to message triggers.""" def __init__(self, bot): self.bot = bot async def on_message(self, ctx: discord.Message): """ - A command to send the seasonalbot github project + A command to send the seasonalbot github project. Lines that begin with the bot's command prefix are ignored Seasonalbot's own messages are ignored """ + for trigger in SPOOKY_TRIGGERS.keys(): trigger_test = re.search(SPOOKY_TRIGGERS[trigger][0], ctx.content.lower()) if trigger_test: @@ -52,6 +50,7 @@ class SpookyReact: * author is the bot * prefix is not None """ + # Check for self reaction if ctx.author == self.bot.user: logging.debug(f"Ignoring reactions on self message. Message ID: {ctx.id}") @@ -68,5 +67,7 @@ class SpookyReact: def setup(bot): + """Spooky reaction Cog load.""" + bot.add_cog(SpookyReact(bot)) log.debug("SpookyReact cog loaded") diff --git a/bot/seasons/halloween/spookysound.py b/bot/seasons/halloween/spookysound.py index 4cab1239..cff50897 100644 --- a/bot/seasons/halloween/spookysound.py +++ b/bot/seasons/halloween/spookysound.py @@ -11,9 +11,7 @@ log = logging.getLogger(__name__) class SpookySound: - """ - A cog that plays a spooky sound in a voice channel on command. - """ + """A cog that plays a spooky sound in a voice channel on command.""" def __init__(self, bot): self.bot = bot @@ -24,9 +22,11 @@ class SpookySound: @commands.command(brief="Play a spooky sound, restricted to once per 2 mins") async def spookysound(self, ctx): """ - Connect to the Hacktoberbot voice channel, play a random spooky sound, then disconnect. Cannot be used more than - once in 2 minutes. + Connect to the Hacktoberbot voice channel, play a random spooky sound, then disconnect. + + Cannot be used more than once in 2 minutes. """ + if not self.channel: await self.bot.wait_until_ready() self.channel = self.bot.get_channel(Hacktoberfest.voice_id) @@ -39,12 +39,12 @@ class SpookySound: @staticmethod async def disconnect(voice): - """ - Helper method to disconnect a given voice client. - """ + """Helper method to disconnect a given voice client.""" await voice.disconnect() def setup(bot): + """Spooky sound Cog load.""" + bot.add_cog(SpookySound(bot)) log.debug("SpookySound cog loaded") diff --git a/bot/seasons/season.py b/bot/seasons/season.py index ae12057f..9dac51e2 100644 --- a/bot/seasons/season.py +++ b/bot/seasons/season.py @@ -19,9 +19,7 @@ log = logging.getLogger(__name__) def get_seasons() -> List[str]: - """ - Returns all the Season objects located in bot/seasons/ - """ + """Returns all the Season objects located in /bot/seasons/.""" seasons = [] @@ -32,9 +30,7 @@ def get_seasons() -> List[str]: def get_season_class(season_name: str) -> Type["SeasonBase"]: - """ - Get's the season class of the season module. - """ + """Gets the season class of the season module.""" season_lib = importlib.import_module(f"bot.seasons.{season_name}") class_name = season_name.replace("_", " ").title().replace(" ", "") @@ -42,9 +38,7 @@ def get_season_class(season_name: str) -> Type["SeasonBase"]: def get_season(season_name: str = None, date: datetime.datetime = None) -> "SeasonBase": - """ - Returns a Season object based on either a string or a date. - """ + """Returns a Season object based on either a string or a date.""" # If either both or neither are set, raise an error. if not bool(season_name) ^ bool(date): @@ -78,9 +72,7 @@ def get_season(season_name: str = None, date: datetime.datetime = None) -> "Seas class SeasonBase: - """ - Base class for Seasonal classes. - """ + """Base class for Seasonal classes.""" name: Optional[str] = "evergreen" bot_name: str = "SeasonalBot" @@ -96,9 +88,7 @@ class SeasonBase: @staticmethod def current_year() -> int: - """ - Returns the current year. - """ + """Returns the current year.""" return datetime.date.today().year @@ -107,8 +97,7 @@ class SeasonBase: """ Returns the start date using current year and start_date attribute. - If no start_date was defined, returns the minimum datetime to ensure - it's always below checked dates. + If no start_date was defined, returns the minimum datetime to ensure it's always below checked dates. """ if not cls.start_date: @@ -120,8 +109,7 @@ class SeasonBase: """ Returns the start date using current year and end_date attribute. - If no end_date was defined, returns the minimum datetime to ensure - it's always above checked dates. + If no end_date was defined, returns the minimum datetime to ensure it's always above checked dates. """ if not cls.end_date: @@ -130,37 +118,36 @@ class SeasonBase: @classmethod def is_between_dates(cls, date: datetime.datetime) -> bool: - """ - Determines if the given date falls between the season's date range. - """ + """Determines if the given date falls between the season's date range.""" return cls.start() <= date <= cls.end() @property def name_clean(self) -> str: + """Return the Season's name with underscores replaced by whitespace.""" + return self.name.replace("_", " ").title() @property def greeting(self) -> str: """ - Provides a default greeting based on the season name if one wasn't - defined in the season class. + Provides a default greeting based on the season name if one wasn't defined in the season class. - It's recommended to define one in most cases by overwriting this as a - normal attribute in the inhertiting class. + It's recommended to define one in most cases by overwriting this as a normal attribute in the + inheriting class. """ return f"New Season, {self.name_clean}!" async def get_icon(self, avatar: bool = False) -> bytes: """ - Retrieves the icon image from the branding repository, using the - defined icon attribute for the season. If `avatar` is True, uses - optional bot-only avatar icon if present. + Retrieve the season's icon from the branding repository using the Season's icon attribute. + + If `avatar` is True, uses optional bot-only avatar icon if present. - The icon attribute must provide the url path, starting from the master - branch base url, including the starting slash: - `https://raw.githubusercontent.com/python-discord/branding/master` + The icon attribute must provide the url path, starting from the master branch base url, + including the starting slash. + e.g. `/logos/logo_seasonal/valentines/loved_up.png` """ base_url = "https://raw.githubusercontent.com/python-discord/branding/master" @@ -175,8 +162,9 @@ class SeasonBase: async def apply_username(self, *, debug: bool = False) -> Union[bool, None]: """ - Applies the username for the current season. Only changes nickname if - `bool` is False, otherwise only changes the nickname. + Applies the username for the current season. + + Only changes nickname if `bool` is False, otherwise only changes the nickname. Returns True if it successfully changed the username. Returns False if it failed to change the username, falling back to nick. @@ -216,7 +204,9 @@ class SeasonBase: async def apply_avatar(self) -> bool: """ - Applies the avatar for the current season. Returns if it was successful. + Applies the avatar for the current season. + + Returns True if successful. """ # track old avatar hash for later comparison @@ -238,7 +228,9 @@ class SeasonBase: async def apply_server_icon(self) -> bool: """ - Applies the server icon for the current season. Returns if it was successful. + Applies the server icon for the current season. + + Returns True if was successful. """ guild = bot.get_guild(Client.guild) @@ -265,8 +257,7 @@ class SeasonBase: """ Announces a change in season in the announcement channel. - It will skip the announcement if the current active season is the - "evergreen" default season. + It will skip the announcement if the current active season is the "evergreen" default season. """ # don't actually announce if reverting to normal season @@ -353,9 +344,7 @@ class SeasonBase: class SeasonManager: - """ - A cog for managing seasons. - """ + """A cog for managing seasons.""" def __init__(self, bot): self.bot = bot @@ -375,6 +364,8 @@ class SeasonManager: self.sleep_time = (midnight - datetime.datetime.now()).seconds + 60 async def load_seasons(self): + """Asynchronous timer loop to check for a new season every midnight.""" + await self.bot.wait_until_ready() await self.season.load() @@ -390,9 +381,7 @@ class SeasonManager: @with_role(Roles.moderator, Roles.admin, Roles.owner) @commands.command(name="season") async def change_season(self, ctx, new_season: str): - """ - Changes the currently active season on the bot. - """ + """Changes the currently active season on the bot.""" self.season = get_season(season_name=new_season) await self.season.load() @@ -401,9 +390,7 @@ class SeasonManager: @with_role(Roles.moderator, Roles.admin, Roles.owner) @commands.command(name="seasons") async def show_seasons(self, ctx): - """ - Shows the available seasons and their dates. - """ + """Shows the available seasons and their dates.""" # sort by start order, followed by lower duration def season_key(season_class: Type[SeasonBase]): @@ -447,17 +434,13 @@ class SeasonManager: @with_role(Roles.moderator, Roles.admin, Roles.owner) @commands.group() async def refresh(self, ctx): - """ - Refreshes certain seasonal elements without reloading seasons. - """ + """Refreshes certain seasonal elements without reloading seasons.""" if not ctx.invoked_subcommand: await ctx.invoke(bot.get_command("help"), "refresh") @refresh.command(name="avatar") async def refresh_avatar(self, ctx): - """ - Re-applies the bot avatar for the currently loaded season. - """ + """Re-applies the bot avatar for the currently loaded season.""" # attempt the change is_changed = await self.season.apply_avatar() @@ -481,9 +464,7 @@ class SeasonManager: @refresh.command(name="icon") async def refresh_server_icon(self, ctx): - """ - Re-applies the server icon for the currently loaded season. - """ + """Re-applies the server icon for the currently loaded season.""" # attempt the change is_changed = await self.season.apply_server_icon() @@ -507,9 +488,7 @@ class SeasonManager: @refresh.command(name="username", aliases=("name",)) async def refresh_username(self, ctx): - """ - Re-applies the bot username for the currently loaded season. - """ + """Re-applies the bot username for the currently loaded season.""" old_username = str(bot.user) old_display_name = ctx.guild.me.display_name @@ -549,9 +528,8 @@ class SeasonManager: @with_role(Roles.moderator, Roles.admin, Roles.owner) @commands.command() async def announce(self, ctx): - """ - Announces the currently loaded season. - """ + """Announces the currently loaded season.""" + await self.season.announce_season() def __unload(self): diff --git a/bot/seasons/valentines/__init__.py b/bot/seasons/valentines/__init__.py index f1489cf9..e3e04421 100644 --- a/bot/seasons/valentines/__init__.py +++ b/bot/seasons/valentines/__init__.py @@ -8,6 +8,7 @@ class Valentines(SeasonBase): Get yourself into the bot-commands channel and check out the new features! """ + name = "valentines" bot_name = "Tenderbot" greeting = "Get loved-up!" diff --git a/bot/seasons/valentines/be_my_valentine.py b/bot/seasons/valentines/be_my_valentine.py index 0046ceb4..6abccceb 100644 --- a/bot/seasons/valentines/be_my_valentine.py +++ b/bot/seasons/valentines/be_my_valentine.py @@ -16,9 +16,7 @@ HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_hea class BeMyValentine: - """ - A cog that sends valentines to other users ! - """ + """A cog that sends Valentines to other users.""" def __init__(self, bot): self.bot = bot @@ -26,6 +24,8 @@ class BeMyValentine: @staticmethod def load_json(): + """Load Valentines messages from the static resources.""" + p = Path('bot', 'resources', 'valentines', 'bemyvalentine_valentines.json') with p.open() as json_data: valentines = load(json_data) @@ -34,19 +34,20 @@ class BeMyValentine: @commands.group(name="lovefest", invoke_without_command=True) async def lovefest_role(self, ctx): """ - You can have yourself the lovefest role or remove it. + Subscribe or unsubscribe from the lovefest role. + The lovefest role makes you eligible to receive anonymous valentines from other users. 1) use the command \".lovefest sub\" to get the lovefest role. 2) use the command \".lovefest unsub\" to get rid of the lovefest role. """ + await ctx.invoke(self.bot.get_command("help"), "lovefest") @lovefest_role.command(name="sub") async def add_role(self, ctx): - """ - This command adds the lovefest role. - """ + """Adds the lovefest role.""" + user = ctx.author role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) if Lovefest.role_id not in [role.id for role in ctx.message.author.roles]: @@ -57,9 +58,8 @@ class BeMyValentine: @lovefest_role.command(name="unsub") async def remove_role(self, ctx): - """ - This command removes the lovefest role. - """ + """Removes the lovefest role.""" + user = ctx.author role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) if Lovefest.role_id not in [role.id for role in ctx.message.author.roles]: @@ -72,7 +72,7 @@ class BeMyValentine: @commands.group(name='bemyvalentine', invoke_without_command=True) async def send_valentine(self, ctx, user: typing.Optional[discord.Member] = None, *, valentine_type=None): """ - This command sends valentine to user if specified or a random user having lovefest role. + Send a valentine to user, if specified, or to a random user with the lovefest role. syntax: .bemyvalentine [user](optional) [p/poem/c/compliment/or you can type your own valentine message] (optional) @@ -119,7 +119,7 @@ class BeMyValentine: @send_valentine.command(name='secret') async def anonymous(self, ctx, user: typing.Optional[discord.Member] = None, *, valentine_type=None): """ - This command DMs a valentine to be given anonymous to a user if specified or a random user having lovefest role. + Send an anonymous Valentine via DM to to a user, if specified, or to a random with the lovefest role. **This command should be DMed to the bot.** @@ -171,6 +171,8 @@ class BeMyValentine: await ctx.author.send(f"Your message has been sent to {user}") def valentine_check(self, valentine_type): + """Return the appropriate Valentine type & title based on the invoking user's input.""" + if valentine_type is None: valentine, title = self.random_valentine() @@ -191,12 +193,14 @@ class BeMyValentine: @staticmethod def random_user(author, members): """ - Picks a random member from the list provided in `members`, ensuring - the author is not one of the options. + Picks a random member from the list provided in `members`. + + The invoking author is ignored. :param author: member who invoked the command :param members: list of discord.Member objects """ + if author in members: members.remove(author) @@ -204,14 +208,15 @@ class BeMyValentine: @staticmethod def random_emoji(): + """Return two random emoji from the module-defined constants.""" + EMOJI_1 = random.choice(HEART_EMOJIS) EMOJI_2 = random.choice(HEART_EMOJIS) return EMOJI_1, EMOJI_2 def random_valentine(self): - """ - Grabs a random poem or a compliment (any message). - """ + """Grabs a random poem or a compliment (any message).""" + valentine_poem = random.choice(self.valentines['valentine_poems']) valentine_compliment = random.choice(self.valentines['valentine_compliments']) random_valentine = random.choice([valentine_compliment, valentine_poem]) @@ -222,20 +227,20 @@ class BeMyValentine: return random_valentine, title def valentine_poem(self): - """ - Grabs a random poem. - """ + """Grabs a random poem.""" + valentine_poem = random.choice(self.valentines['valentine_poems']) return valentine_poem def valentine_compliment(self): - """ - Grabs a random compliment. - """ + """Grabs a random compliment.""" + valentine_compliment = random.choice(self.valentines['valentine_compliments']) return valentine_compliment def setup(bot): + """Be my Valentine Cog load.""" + bot.add_cog(BeMyValentine(bot)) log.debug("Be My Valentine cog loaded") diff --git a/bot/seasons/valentines/lovecalculator.py b/bot/seasons/valentines/lovecalculator.py index 4df33b93..56cb551d 100644 --- a/bot/seasons/valentines/lovecalculator.py +++ b/bot/seasons/valentines/lovecalculator.py @@ -21,9 +21,7 @@ with Path('bot', 'resources', 'valentines', 'love_matches.json').open() as file: class LoveCalculator: - """ - A cog for calculating the love between two people - """ + """A cog for calculating the love between two people.""" def __init__(self, bot): self.bot = bot @@ -103,4 +101,6 @@ class LoveCalculator: def setup(bot): + """Love calculator Cog load.""" + bot.add_cog(LoveCalculator(bot)) diff --git a/bot/seasons/valentines/movie_generator.py b/bot/seasons/valentines/movie_generator.py index b52eba7f..19fbf25e 100644 --- a/bot/seasons/valentines/movie_generator.py +++ b/bot/seasons/valentines/movie_generator.py @@ -12,18 +12,15 @@ log = logging.getLogger(__name__) class RomanceMovieFinder: - """ - A cog that returns a random romance movie suggestion to a user - """ + """A cog that returns a random romance movie suggestion to a user.""" def __init__(self, bot): self.bot = bot @commands.command(name="romancemovie") async def romance_movie(self, ctx): - """ - Randomly selects a romance movie and displays information about it - """ + """Randomly selects a romance movie and displays information about it.""" + # selecting a random int to parse it to the page parameter random_page = random.randint(0, 20) # TMDB api params @@ -62,5 +59,7 @@ class RomanceMovieFinder: def setup(bot): + """Romance movie Cog load.""" + bot.add_cog(RomanceMovieFinder(bot)) log.debug("Random romance movie cog loaded!") diff --git a/bot/seasons/valentines/myvalenstate.py b/bot/seasons/valentines/myvalenstate.py index 9f06553d..46fdebbc 100644 --- a/bot/seasons/valentines/myvalenstate.py +++ b/bot/seasons/valentines/myvalenstate.py @@ -16,13 +16,14 @@ with open(Path('bot', 'resources', 'valentines', 'valenstates.json'), 'r') as fi class MyValenstate: + """A Cog to find your most likely Valentine's vacation destination.""" + def __init__(self, bot): self.bot = bot def levenshtein(self, source, goal): - """ - Calculates the Levenshtein Distance between source and goal. - """ + """Calculates the Levenshtein Distance between source and goal.""" + if len(source) < len(goal): return self.levenshtein(goal, source) if len(source) == 0: @@ -43,6 +44,8 @@ class MyValenstate: @commands.command() async def myvalenstate(self, ctx, *, name=None): + """Find the vacation spot(s) with the most matching characters to the invoking user.""" + eq_chars = collections.defaultdict(int) if name is None: author = ctx.message.author.name.lower().replace(' ', '') @@ -81,5 +84,7 @@ class MyValenstate: def setup(bot): + """Valenstate Cog load.""" + bot.add_cog(MyValenstate(bot)) log.debug("MyValenstate cog loaded") diff --git a/bot/seasons/valentines/pickuplines.py b/bot/seasons/valentines/pickuplines.py index 4462478f..bce5cdcc 100644 --- a/bot/seasons/valentines/pickuplines.py +++ b/bot/seasons/valentines/pickuplines.py @@ -15,9 +15,7 @@ with open(Path('bot', 'resources', 'valentines', 'pickup_lines.json'), 'r', enco class PickupLine: - """ - A cog that gives random cheesy pickup lines. - """ + """A cog that gives random cheesy pickup lines.""" def __init__(self, bot): self.bot = bot @@ -25,8 +23,11 @@ class PickupLine: @commands.command() async def pickupline(self, ctx): """ - Gives you a random pickup line. Note that most of them are very cheesy! + Gives you a random pickup line. + + Note that most of them are very cheesy. """ + random_line = random.choice(pickup_lines['lines']) embed = discord.Embed( title=':cheese: Your pickup line :cheese:', @@ -40,5 +41,7 @@ class PickupLine: def setup(bot): + """Pickup lines Cog load.""" + bot.add_cog(PickupLine(bot)) log.info('Pickup line cog loaded') diff --git a/bot/seasons/valentines/savethedate.py b/bot/seasons/valentines/savethedate.py index f460283a..9582dea4 100644 --- a/bot/seasons/valentines/savethedate.py +++ b/bot/seasons/valentines/savethedate.py @@ -17,18 +17,14 @@ with open(Path('bot', 'resources', 'valentines', 'date_ideas.json'), 'r', encodi class SaveTheDate: - """ - A cog that gives random suggestion, for a valentines date ! - """ + """A cog that gives random suggestion for a Valentine's date.""" def __init__(self, bot): self.bot = bot @commands.command() async def savethedate(self, ctx): - """ - Gives you ideas for what to do on a date with your valentine. - """ + """Gives you ideas for what to do on a date with your valentine.""" random_date = random.choice(VALENTINES_DATES['ideas']) emoji_1 = random.choice(HEART_EMOJIS) emoji_2 = random.choice(HEART_EMOJIS) @@ -41,5 +37,7 @@ class SaveTheDate: def setup(bot): + """Save the date Cog Load.""" + bot.add_cog(SaveTheDate(bot)) log.debug("Save the date cog loaded") diff --git a/bot/seasons/valentines/valentine_zodiac.py b/bot/seasons/valentines/valentine_zodiac.py index 06c0237d..e18196fc 100644 --- a/bot/seasons/valentines/valentine_zodiac.py +++ b/bot/seasons/valentines/valentine_zodiac.py @@ -15,15 +15,16 @@ HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_hea class ValentineZodiac: - """ - A cog that returns a counter compatible zodiac sign to the given user's zodiac sign. - """ + """A cog that returns a counter compatible zodiac sign to the given user's zodiac sign.""" + def __init__(self, bot): self.bot = bot self.zodiacs = self.load_json() @staticmethod def load_json(): + """Load Zodiac compatibility from static JSON resource.""" + p = Path('bot', 'resources', 'valentines', 'zodiac_compatibility.json') with p.open() as json_data: zodiacs = load(json_data) @@ -31,9 +32,8 @@ class ValentineZodiac: @commands.command(name="partnerzodiac") async def counter_zodiac(self, ctx, zodiac_sign): - """ - Provides a counter compatible zodiac sign to the given user's zodiac sign. - """ + """Provides a counter compatible zodiac sign to the given user's zodiac sign.""" + try: compatible_zodiac = random.choice(self.zodiacs[zodiac_sign.lower()]) except KeyError: @@ -55,5 +55,7 @@ class ValentineZodiac: def setup(bot): + """Valentine Zodiac Cog load.""" + bot.add_cog(ValentineZodiac(bot)) log.debug("Valentine Zodiac cog loaded") diff --git a/bot/seasons/valentines/whoisvalentine.py b/bot/seasons/valentines/whoisvalentine.py index 2fe07aba..9ac2ee94 100644 --- a/bot/seasons/valentines/whoisvalentine.py +++ b/bot/seasons/valentines/whoisvalentine.py @@ -15,14 +15,15 @@ with open(Path("bot", "resources", "valentines", "valentine_facts.json"), "r") a class ValentineFacts: + """A Cog for displaying facts about Saint Valentine.""" + def __init__(self, bot): self.bot = bot @commands.command(aliases=('whoisvalentine', 'saint_valentine')) async def who_is_valentine(self, ctx): - """ - Displays info about Saint Valentine. - """ + """Displays info about Saint Valentine.""" + embed = discord.Embed( title="Who is Saint Valentine?", description=FACTS['whois'], @@ -37,9 +38,8 @@ class ValentineFacts: @commands.command() async def valentine_fact(self, ctx): - """ - Shows a random fact about Valentine's Day. - """ + """Shows a random fact about Valentine's Day.""" + embed = discord.Embed( title=choice(FACTS['titles']), description=choice(FACTS['text']), @@ -50,4 +50,6 @@ class ValentineFacts: def setup(bot): + """Who is Valentine Cog load.""" + bot.add_cog(ValentineFacts(bot)) diff --git a/bot/utils/halloween/spookifications.py b/bot/utils/halloween/spookifications.py index 5f2369ae..390cfa49 100644 --- a/bot/utils/halloween/spookifications.py +++ b/bot/utils/halloween/spookifications.py @@ -8,17 +8,20 @@ log = logging.getLogger() def inversion(im): - """Inverts an image. + """ + Inverts the image. Returns an inverted image when supplied with an Image object. """ + im = im.convert('RGB') inv = ImageOps.invert(im) return inv def pentagram(im): - """Adds pentagram to image.""" + """Adds pentagram to the image.""" + im = im.convert('RGB') wt, ht = im.size penta = Image.open('bot/resources/halloween/bloody-pentagram.png') @@ -28,10 +31,13 @@ def pentagram(im): def bat(im): - """Adds a bat silhoutte to the image. + """ + Adds a bat silhoutte to the image. + + The bat silhoutte is of a size at least one-fifths that of the original image and may be rotated + up to 90 degrees anti-clockwise. + """ - The bat silhoutte is of a size at least one-fifths that of the original - image and may be rotated upto 90 degrees anti-clockwise.""" im = im.convert('RGB') wt, ht = im.size bat = Image.open('bot/resources/halloween/bat-clipart.png') @@ -49,6 +55,7 @@ def bat(im): def get_random_effect(im): """Randomly selects and applies an effect.""" + effects = [inversion, pentagram, bat] effect = choice(effects) log.info("Spookyavatar's chosen effect: " + effect.__name__) @@ -1,6 +1,19 @@ [flake8] max-line-length=120 application_import_names=bot -ignore=P102,B311,W503,E226,S311 -exclude=__pycache__,venv,.venv,tests,.cache +ignore= + P102,B311,W503,E226,S311, + # Missing Docstrings + D100,D104,D107, + # Docstring Whitespace + D202,D203,D204,D212,D214,D215, + # Docstring Quotes + D301,D302, + # Docstring Content + D400,D401,D402,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414 +exclude= + __pycache__,.cache, + venv,.venv, + tests, + constants.py import-order-style=pycharm |