From b9c5c6ab10934bdf7fd6c8240e1bb3b976df833b Mon Sep 17 00:00:00 2001 From: stijndcl Date: Wed, 13 Jul 2022 22:54:16 +0200 Subject: [PATCH] Add flake8 docstring plugin, formatting, create base class for embeds & models --- .flake8 | 25 ++++++++- database/crud/ufora_announcements.py | 1 + database/crud/users.py | 3 +- didier/cogs/help.py | 5 +- didier/cogs/other.py | 23 ++++++++ didier/cogs/owner.py | 5 +- didier/data/apis/__init__.py | 0 didier/data/apis/urban_dictionary.py | 14 +++++ didier/data/embeds/base.py | 22 ++++++++ didier/data/embeds/ufora/announcements.py | 3 +- didier/data/embeds/urban_dictionary.py | 24 ++++++++ didier/data/modals/custom_commands.py | 8 ++- didier/didier.py | 7 ++- didier/exceptions/__init__.py | 0 didier/exceptions/config.py | 12 ++++ didier/utils/discord/colours.py | 7 +++ didier/utils/discord/converters/numbers.py | 1 + didier/utils/discord/prefix.py | 1 + didier/utils/types/string.py | 1 + pyproject.toml | 1 + requirements-dev.txt | 2 +- requirements.txt | 3 + settings.py | 33 +++++++++-- .../test_data/urban_dictionary_response.json | 56 +++++++++++++++++++ 24 files changed, 241 insertions(+), 16 deletions(-) create mode 100644 didier/cogs/other.py create mode 100644 didier/data/apis/__init__.py create mode 100644 didier/data/apis/urban_dictionary.py create mode 100644 didier/data/embeds/base.py create mode 100644 didier/data/embeds/urban_dictionary.py create mode 100644 didier/exceptions/__init__.py create mode 100644 didier/exceptions/config.py create mode 100644 didier/utils/discord/colours.py create mode 100644 tests/test_data/urban_dictionary_response.json diff --git a/.flake8 b/.flake8 index 3b71da3..259dc4e 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,5 @@ [flake8] +# Don't lint non-Python files exclude = .git, .github, @@ -9,10 +10,30 @@ exclude = htmlcov, tests, venv -ignore=E203 +# Disable rules that we don't care about (or conflict with others) +extend-ignore = + # Missing docstring in public module + D100, D104, + # Missing docstring in magic method + D105, + # Missing docstring in __init__ + D107, + # First line of docstrings should end with a period + D400, + # First line of docstrings should be in imperative mood + D401, + # Whitespace before ":" + E203, +# Don't require docstrings when overriding a method, +# the base method should have a docstring but the rest not +ignore-decorator=overrides max-line-length = 120 +# Disable some rules for entire files per-file-ignores = # Missing __all__, main isn't supposed to be imported main.py: DALL000, # Missing __all__, Cogs aren't modules - ./didier/cogs/*: DALL000 \ No newline at end of file + ./didier/cogs/*: DALL000, + # All of the colours methods are just oneliners to create a colour, + # there's no point adding docstrings (function names are enough) + ./didier/utils/discord/colours.py: D103 diff --git a/database/crud/ufora_announcements.py b/database/crud/ufora_announcements.py index 5f82390..48a06ae 100644 --- a/database/crud/ufora_announcements.py +++ b/database/crud/ufora_announcements.py @@ -28,6 +28,7 @@ async def create_new_announcement( async def remove_old_announcements(session: AsyncSession): """Delete all announcements that are > 8 days old + The RSS feed only goes back 7 days, so all of these old announcements never have to be checked again when checking if an announcement is fresh or not. """ diff --git a/database/crud/users.py b/database/crud/users.py index cc5a899..57c5029 100644 --- a/database/crud/users.py +++ b/database/crud/users.py @@ -12,7 +12,8 @@ __all__ = [ async def get_or_add(session: AsyncSession, user_id: int) -> User: """Get a user's profile - If it doesn't exist yet, create it (along with all linked datastructures). + + If it doesn't exist yet, create it (along with all linked datastructures) """ statement = select(User).where(User.user_id == user_id) user: Optional[User] = (await session.execute(statement)).scalar_one_or_none() diff --git a/didier/cogs/help.py b/didier/cogs/help.py index 33250a8..77ae0f1 100644 --- a/didier/cogs/help.py +++ b/didier/cogs/help.py @@ -2,13 +2,15 @@ from typing import List, Mapping, Optional import discord from discord.ext import commands +from overrides import overrides from didier import Didier class CustomHelpCommand(commands.MinimalHelpCommand): """Customised Help command to override the default implementation - The default is ugly as hell + + The default is ugly as hell so we do some fiddling with it """ def _help_embed_base(self, title: str) -> discord.Embed: @@ -30,6 +32,7 @@ class CustomHelpCommand(commands.MinimalHelpCommand): return list(sorted(filtered_cogs, key=lambda cog: cog.qualified_name)) + @overrides async def send_bot_help(self, mapping: Mapping[Optional[commands.Cog], List[commands.Command]], /): embed = self._help_embed_base("Categorieën") filtered_cogs = await self._filter_cogs(list(mapping.keys())) diff --git a/didier/cogs/other.py b/didier/cogs/other.py new file mode 100644 index 0000000..93098ac --- /dev/null +++ b/didier/cogs/other.py @@ -0,0 +1,23 @@ +from discord.ext import commands + +from didier import Didier +from didier.data.apis import urban_dictionary + + +class Other(commands.Cog): + """Cog for commands that don't really belong anywhere else""" + + client: Didier + + def __init__(self, client: Didier): + self.client = client + + @commands.command(name="Define", aliases=["Ud", "Urban"], usage="[Woord]") + async def define(self, ctx: commands.Context, *, query: str): + """Look up the definition of a word on the Urban Dictionary""" + definitions = urban_dictionary.lookup(self.client.http_session, query) + + +async def setup(client: Didier): + """Load the cog""" + await client.add_cog(Other(client)) diff --git a/didier/cogs/owner.py b/didier/cogs/owner.py index 401067c..ca72cd6 100644 --- a/didier/cogs/owner.py +++ b/didier/cogs/owner.py @@ -25,8 +25,9 @@ class Owner(commands.Cog): self.client = client async def cog_check(self, ctx: commands.Context) -> bool: - """Global check for every command in this cog, so we don't have to add - is_owner() to every single command separately + """Global check for every command in this cog + + This means that we don't have to add is_owner() to every single command separately """ # pylint: disable=W0236 # Pylint thinks this can't be async, but it can return await self.client.is_owner(ctx.author) diff --git a/didier/data/apis/__init__.py b/didier/data/apis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/didier/data/apis/urban_dictionary.py b/didier/data/apis/urban_dictionary.py new file mode 100644 index 0000000..ac42b9c --- /dev/null +++ b/didier/data/apis/urban_dictionary.py @@ -0,0 +1,14 @@ +from aiohttp import ClientSession + +from didier.data.embeds.urban_dictionary import Definition + +__all__ = ["lookup"] + + +async def lookup(http_session: ClientSession, query: str) -> list[Definition]: + """Fetch the Urban Dictionary definitions for a given word""" + url = "https://api.urbandictionary.com/v0/define" + + async with http_session.get(url, params={"term": query}) as response: + response_json = await response.json() + return list(map(Definition.parse_obj, response_json["list"])) diff --git a/didier/data/embeds/base.py b/didier/data/embeds/base.py new file mode 100644 index 0000000..38d7c61 --- /dev/null +++ b/didier/data/embeds/base.py @@ -0,0 +1,22 @@ +from abc import ABC, abstractmethod + +import discord +from pydantic import BaseModel + +__all__ = [ + "EmbedBaseModel", + "EmbedPydantic", +] + + +class EmbedBaseModel(ABC): + """Abstract base class for a model that can be turned into a Discord embed""" + + @abstractmethod + def to_embed(self) -> discord.Embed: + """Turn this model into a Discord embed""" + raise NotImplementedError + + +class EmbedPydantic(ABC, EmbedBaseModel, BaseModel): + """Pydantic version of EmbedModel""" diff --git a/didier/data/embeds/ufora/announcements.py b/didier/data/embeds/ufora/announcements.py index 984674e..f4a8bdd 100644 --- a/didier/data/embeds/ufora/announcements.py +++ b/didier/data/embeds/ufora/announcements.py @@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession import settings from database.crud import ufora_announcements as crud from database.models import UforaCourse +from didier.data.embeds.base import EmbedBaseModel from didier.utils.types.datetime import int_to_weekday from didier.utils.types.string import leading @@ -25,7 +26,7 @@ __all__ = [ @dataclass -class UforaNotification: +class UforaNotification(EmbedBaseModel): """A single notification from Ufora""" content: dict diff --git a/didier/data/embeds/urban_dictionary.py b/didier/data/embeds/urban_dictionary.py new file mode 100644 index 0000000..99a9a92 --- /dev/null +++ b/didier/data/embeds/urban_dictionary.py @@ -0,0 +1,24 @@ +from datetime import datetime + +import discord +from overrides import overrides + +from didier.data.embeds.base import EmbedPydantic + +__all__ = ["Definition"] + + +class Definition(EmbedPydantic): + """A definition from the Urban Dictionary""" + + word: str + definition: str + permalink: str + author: str + thumbs_up: int + thumbs_down: int + written_on: datetime + + @overrides + def to_embed(self) -> discord.Embed: + embed = discord.Embed() diff --git a/didier/data/modals/custom_commands.py b/didier/data/modals/custom_commands.py index 65c259f..35ac158 100644 --- a/didier/data/modals/custom_commands.py +++ b/didier/data/modals/custom_commands.py @@ -2,6 +2,7 @@ import traceback import typing import discord +from overrides import overrides from database.crud.custom_commands import create_command, edit_command from didier import Didier @@ -24,12 +25,14 @@ class CreateCustomCommand(discord.ui.Modal, title="Create Custom Command"): super().__init__(*args, **kwargs) self.client = client + @overrides async def on_submit(self, interaction: discord.Interaction): async with self.client.db_session as session: command = await create_command(session, str(self.name.value), str(self.response.value)) await interaction.response.send_message(f"Successfully created ``{command.name}``.", ephemeral=True) + @overrides async def on_error(self, interaction: discord.Interaction, error: Exception): # type: ignore await interaction.response.send_message("Something went wrong.", ephemeral=True) traceback.print_tb(error.__traceback__) @@ -37,7 +40,8 @@ class CreateCustomCommand(discord.ui.Modal, title="Create Custom Command"): class EditCustomCommand(discord.ui.Modal, title="Edit Custom Command"): """Modal to edit an existing custom command - Fills in the current values as defaults + + Fills in the current values as defaults for QOL """ name: discord.ui.TextInput @@ -59,6 +63,7 @@ class EditCustomCommand(discord.ui.Modal, title="Edit Custom Command"): ) ) + @overrides async def on_submit(self, interaction: discord.Interaction): name_field = typing.cast(discord.ui.TextInput, self.children[0]) response_field = typing.cast(discord.ui.TextInput, self.children[1]) @@ -68,6 +73,7 @@ class EditCustomCommand(discord.ui.Modal, title="Edit Custom Command"): await interaction.response.send_message(f"Successfully edited ``{self.original_name}``.", ephemeral=True) + @overrides async def on_error(self, interaction: discord.Interaction, error: Exception): # type: ignore await interaction.response.send_message("Something went wrong.", ephemeral=True) traceback.print_tb(error.__traceback__) diff --git a/didier/didier.py b/didier/didier.py index 14a9943..fbe855b 100644 --- a/didier/didier.py +++ b/didier/didier.py @@ -42,7 +42,10 @@ class Didier(commands.Bot): return DBSession() async def setup_hook(self) -> None: - """Hook called once the bot is initialised""" + """Do some initial setup + + This hook is called once the bot is initialised + """ # Load extensions await self._load_initial_extensions() await self._load_directory_extensions("didier/cogs") @@ -113,7 +116,9 @@ class Didier(commands.Bot): async def _try_invoke_custom_command(self, message: discord.Message) -> bool: """Check if the message tries to invoke a custom command + If it does, send the reply associated with it + Returns a boolean indicating if a message invoked a command or not """ # Doesn't start with the custom command prefix if not message.content.startswith(settings.DISCORD_CUSTOM_COMMAND_PREFIX): diff --git a/didier/exceptions/__init__.py b/didier/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/didier/exceptions/config.py b/didier/exceptions/config.py new file mode 100644 index 0000000..df73f7e --- /dev/null +++ b/didier/exceptions/config.py @@ -0,0 +1,12 @@ +__all__ = ["MissingEnvironmentVariable"] + + +class MissingEnvironmentVariable(RuntimeError): + """Exception raised when an environment variable is missing + + These are not necessarily checked on startup, because they may be unused + during a given test run, and random unrelated crashes would be annoying + """ + + def __init__(self, variable: str): + super().__init__(f"Missing environment variable: {variable}") diff --git a/didier/utils/discord/colours.py b/didier/utils/discord/colours.py new file mode 100644 index 0000000..ffcd8eb --- /dev/null +++ b/didier/utils/discord/colours.py @@ -0,0 +1,7 @@ +import discord + +__all__ = ["urban_dictionary_green"] + + +def urban_dictionary_green() -> discord.Colour: + return discord.Colour.from_rgb(220, 255, 0) diff --git a/didier/utils/discord/converters/numbers.py b/didier/utils/discord/converters/numbers.py index 573652f..61d991a 100644 --- a/didier/utils/discord/converters/numbers.py +++ b/didier/utils/discord/converters/numbers.py @@ -6,6 +6,7 @@ __all__ = ["abbreviated_number"] def abbreviated_number(argument: str) -> Union[str, int]: """Custom converter to allow numbers to be abbreviated + Examples: 515k 4m diff --git a/didier/utils/discord/prefix.py b/didier/utils/discord/prefix.py index f57e06d..df62ad4 100644 --- a/didier/utils/discord/prefix.py +++ b/didier/utils/discord/prefix.py @@ -10,6 +10,7 @@ __all__ = ["get_prefix"] def get_prefix(client: commands.Bot, message: Message) -> str: """Match a prefix against a message + This is done dynamically to allow variable amounts of whitespace, and through regexes to allow case-insensitivity among other things. """ diff --git a/didier/utils/types/string.py b/didier/utils/types/string.py index ab4f415..3f383a6 100644 --- a/didier/utils/types/string.py +++ b/didier/utils/types/string.py @@ -6,6 +6,7 @@ __all__ = ["leading", "pluralize"] def leading(character: str, string: str, target_length: Optional[int] = 2) -> str: """Add a leading [character] to [string] to make it length [target_length] + Pass None to target length to always do it (once), no matter the length """ # Cast to string just in case diff --git a/pyproject.toml b/pyproject.toml index ba0c283..a5ac198 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ profile = "black" [tool.mypy] plugins = [ + "pydantic.mypy", "sqlalchemy.ext.mypy.plugin" ] [[tool.mypy.overrides]] diff --git a/requirements-dev.txt b/requirements-dev.txt index 6c6e38e..533740c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,8 +11,8 @@ types-pytz==2021.3.8 # Flake8 + plugins flake8==4.0.1 flake8-bandit==3.0.0 -flake8-black==0.3.3 flake8-bugbear==22.7.1 +flake8-docstrings==1.6.0 flake8-dunder-all==0.2.1 flake8-eradicate==1.2.1 flake8-isort==4.1.1 diff --git a/requirements.txt b/requirements.txt index 1b4240a..0737e1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,5 +6,8 @@ git+https://github.com/Rapptz/discord.py environs==9.5.0 feedparser==6.0.10 markdownify==0.11.2 +overrides==6.1.0 +pydantic==1.9.1 +python-dateutil==2.8.2 pytz==2022.1 sqlalchemy[asyncio]==1.4.37 diff --git a/settings.py b/settings.py index d850eb8..bc35ab4 100644 --- a/settings.py +++ b/settings.py @@ -6,6 +6,26 @@ from environs import Env env = Env() env.read_env() +__all__ = [ + "SANDBOX", + "LOGFILE", + "DB_NAME", + "DB_USERNAME", + "DB_PASSWORD", + "DB_HOST", + "DB_PORT", + "DISCORD_TOKEN", + "DISCORD_READY_MESSAGE", + "DISCORD_STATUS_MESSAGE", + "DISCORD_TEST_GUILDS", + "DISCORD_BOOS_REACT", + "DISCORD_CUSTOM_COMMAND_PREFIX", + "UFORA_ANNOUNCEMENTS_CHANNEL", + "UFORA_RSS_TOKEN", + "URBAN_DICTIONARY_TOKEN", +] + + """General config""" SANDBOX: bool = env.bool("SANDBOX", True) LOGFILE: str = env.str("LOGFILE", "didier.log") @@ -18,13 +38,14 @@ DB_HOST: str = env.str("DB_HOST", "localhost") DB_PORT: int = env.int("DB_PORT", "5432") """Discord""" -DISCORD_TOKEN: str = env.str("DISC_TOKEN") -DISCORD_READY_MESSAGE: str = env.str("DISC_READY_MESSAGE", "I'M READY I'M READY I'M READY") -DISCORD_STATUS_MESSAGE: str = env.str("DISC_STATUS_MESSAGE", "with your Didier Dinks.") -DISCORD_TEST_GUILDS: list[int] = env.list("DISC_TEST_GUILDS", [], subcast=int) -DISCORD_BOOS_REACT: str = env.str("DISC_BOOS_REACT", "<:boos:629603785840263179>") -DISCORD_CUSTOM_COMMAND_PREFIX: str = env.str("DISC_CUSTOM_COMMAND_PREFIX", "?") +DISCORD_TOKEN: str = env.str("DISCORD_TOKEN") +DISCORD_READY_MESSAGE: str = env.str("DISCORD_READY_MESSAGE", "I'M READY I'M READY I'M READY") +DISCORD_STATUS_MESSAGE: str = env.str("DISCORD_STATUS_MESSAGE", "with your Didier Dinks.") +DISCORD_TEST_GUILDS: list[int] = env.list("DISCORD_TEST_GUILDS", [], subcast=int) +DISCORD_BOOS_REACT: str = env.str("DISCORD_BOOS_REACT", "<:boos:629603785840263179>") +DISCORD_CUSTOM_COMMAND_PREFIX: str = env.str("DISCORD_CUSTOM_COMMAND_PREFIX", "?") UFORA_ANNOUNCEMENTS_CHANNEL: Optional[int] = env.int("UFORA_ANNOUNCEMENTS_CHANNEL", None) """API Keys""" UFORA_RSS_TOKEN: Optional[str] = env.str("UFORA_RSS_TOKEN", None) +URBAN_DICTIONARY_TOKEN: Optional[str] = env.str("URBAN_DICTIONARY_TOKEN", None) diff --git a/tests/test_data/urban_dictionary_response.json b/tests/test_data/urban_dictionary_response.json new file mode 100644 index 0000000..04cedd5 --- /dev/null +++ b/tests/test_data/urban_dictionary_response.json @@ -0,0 +1,56 @@ +{ + "list": [ + { + "definition": "When you fall [asleep] [tweeting] about [nonsensical] things", + "permalink": "http://cofveve.urbanup.com/11642742", + "thumbs_up": 170, + "sound_urls": [], + "author": "Bonafidé", + "word": "cofveve", + "defid": 11642742, + "current_vote": "", + "written_on": "2017-06-03T00:32:10.987Z", + "example": "[Despite] [the negative] [press] cofveve", + "thumbs_down": 30 + }, + { + "definition": "when you want to type [conference] and your hands are too small to reach [the keys] (someone else's [brill] def)", + "permalink": "http://cofveve.urbanup.com/11662158", + "thumbs_up": 69, + "sound_urls": [], + "author": "blacklist2017", + "word": "cofveve", + "defid": 11662158, + "current_vote": "", + "written_on": "2017-06-07T00:44:36.793Z", + "example": "\"[i want] to [shut down] this [press] cofveve...", + "thumbs_down": 16 + }, + { + "definition": "[Bullshit]", + "permalink": "http://cofveve.urbanup.com/12386593", + "thumbs_up": 5, + "sound_urls": [], + "author": "FreedomTodd", + "word": "Cofveve", + "defid": 12386593, + "current_vote": "", + "written_on": "2018-01-06T17:52:59.822Z", + "example": "[I’m] [full of] [cofveve]", + "thumbs_down": 6 + }, + { + "definition": "When you dip [an apple] in a chocolate [Starbucks] [frappe]", + "permalink": "http://razzle-cofveve.urbanup.com/12006856", + "thumbs_up": 0, + "sound_urls": [], + "author": "harry potter theme song si ", + "word": "razzle cofveve", + "defid": 12006856, + "current_vote": "", + "written_on": "2017-09-30T03:00:54.233Z", + "example": "[My friend] loves [apples] and [Starbucks], so the razzle cofveve was perfect!", + "thumbs_down": 0 + } + ] +} \ No newline at end of file