diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..a0f61ec --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,14 @@ +{ + "features": { + "ghcr.io/devcontainers/features/powershell:1": { + "version": "1.2.0", + "resolved": "ghcr.io/devcontainers/features/powershell@sha256:3b8a159d67a68419cbc13f09413fc3523c1a8f13d64bfb3aa7119df4fd324d0e", + "integrity": "sha256:3b8a159d67a68419cbc13f09413fc3523c1a8f13d64bfb3aa7119df4fd324d0e" + }, + "ghcr.io/devcontainers/features/python:1": { + "version": "1.3.2", + "resolved": "ghcr.io/devcontainers/features/python@sha256:585d4d8ad574891e2ffa2b1a5823a363fc1562121bdedea1c441daf3560f7006", + "integrity": "sha256:585d4d8ad574891e2ffa2b1a5823a363fc1562121bdedea1c441daf3560f7006" + } + } +} diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 863847f..611de21 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,5 +1,10 @@ version: 2 updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: "monthly" + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/docker-build-push.yaml b/.github/workflows/container-build-push.yaml similarity index 70% rename from .github/workflows/docker-build-push.yaml rename to .github/workflows/container-build-push.yaml index 592aff7..2bbb841 100644 --- a/.github/workflows/docker-build-push.yaml +++ b/.github/workflows/container-build-push.yaml @@ -1,4 +1,4 @@ -name: "Docker Build and Push" +name: "Container Build and Push" on: push: @@ -17,4 +17,6 @@ permissions: jobs: build-push: - uses: darbiadev/.github/.github/workflows/docker-build-push.yaml@f185cc076161b47921c6fb6da4c1fd5e40b50bff # v3.0.0 + uses: darbiadev/.github/.github/workflows/docker-build-push.yaml@ea97d99e1520c46080c4c9032a69552e491474ac # v13.0.0 + with: + file-name: Dockerfile diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index a73b23b..b0f7aa2 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -8,11 +8,15 @@ on: jobs: pre-commit: - uses: darbiadev/.github/.github/workflows/generic-precommit.yaml@f185cc076161b47921c6fb6da4c1fd5e40b50bff # v3.0.0 + uses: darbiadev/.github/.github/workflows/generic-precommit.yaml@ea97d99e1520c46080c4c9032a69552e491474ac # v13.0.0 + with: + python-version: "3.11" lint: needs: pre-commit - uses: darbiadev/.github/.github/workflows/python-lint.yaml@f185cc076161b47921c6fb6da4c1fd5e40b50bff # v3.0.0 + uses: darbiadev/.github/.github/workflows/python-lint.yaml@ea97d99e1520c46080c4c9032a69552e491474ac # v13.0.0 + with: + python-version: "3.11" test: needs: lint @@ -21,7 +25,7 @@ jobs: os: [ ubuntu-latest ] python-version: [ "3.11" ] - uses: darbiadev/.github/.github/workflows/python-test.yaml@f185cc076161b47921c6fb6da4c1fd5e40b50bff # v3.0.0 + uses: darbiadev/.github/.github/workflows/python-test.yaml@ea97d99e1520c46080c4c9032a69552e491474ac # v13.0.0 with: os: ${{ matrix.os }} python-version: ${{ matrix.python-version }} @@ -33,4 +37,6 @@ jobs: pages: write id-token: write - uses: darbiadev/.github/.github/workflows/github-pages-python-sphinx.yaml@f185cc076161b47921c6fb6da4c1fd5e40b50bff # v3.0.0 + uses: darbiadev/.github/.github/workflows/github-pages-python-sphinx.yaml@ea97d99e1520c46080c4c9032a69552e491474ac # v13.0.0 + with: + python-version: "3.11" diff --git a/Containerfile b/Dockerfile similarity index 71% rename from Containerfile rename to Dockerfile index d22549a..e43bbf6 100644 --- a/Containerfile +++ b/Dockerfile @@ -1,16 +1,17 @@ FROM python:3.11-slim@sha256:edaf703dce209d774af3ff768fc92b1e3b60261e7602126276f9ceb0e3a96874 -# Define Git SHA build argument for sentry +# Define Git SHA build argument for Sentry ARG git_sha="development" ENV GIT_SHA=$git_sha COPY requirements/requirements.txt . RUN python -m pip install --requirement requirements.txt -COPY . . +COPY pyproject.toml pyproject.toml +COPY src/ src/ RUN python -m pip install . RUN adduser --disabled-password bot USER bot -CMD ["python", "-m", "bot"] +CMD [ "python", "-m", "bot" ] diff --git a/make.ps1 b/make.ps1 index 1f90248..19cb79c 100644 --- a/make.ps1 +++ b/make.ps1 @@ -11,7 +11,7 @@ COMMANDS install-dev install local package in editable mode update-deps update the dependencies upgrade-deps upgrade the dependencies - lint run `pre-commit` and `black` and `ruff` + lint run `pre-commit` and `ruff` and `mypy` test run `pytest` build-dist run `python -m build` clean delete generated content @@ -40,29 +40,29 @@ function Invoke-Install-Dev function Invoke-Update-Deps { - python -m pip install --upgrade --editable ".[dev, tests, docs]" python -m pip install --upgrade pip-tools - pip-compile --output-file requirements/requirements.txt requirements/requirements.in - pip-compile --output-file requirements/requirements-dev.txt requirements/requirements-dev.in - pip-compile --output-file requirements/requirements-tests.txtrequirements/requirements-tests.in - pip-compile --output-file requirements/requirements-docs.txt requirements/requirements-docs.in + pip-compile requirements/requirements.in + pip-compile requirements/requirements-dev.in + pip-compile requirements/requirements-tests.in + pip-compile requirements/requirements-docs.in } function Invoke-Upgrade-Deps { python -m pip install --upgrade pip-tools pre-commit pre-commit autoupdate - pip-compile --upgrade --output-file requirements/requirements.txt requirements/requirements.in - pip-compile --upgrade --output-file requirements/requirements-dev.txt requirements/requirements-dev.in - pip-compile --upgrade --output-file requirements/requirements-tests.txtrequirements/requirements-tests.in - pip-compile --upgrade --output-file requirements/requirements-docs.txt requirements/requirements-docs.in + pip-compile --upgrade requirements/requirements.in + pip-compile --upgrade requirements/requirements-dev.in + pip-compile --upgrade requirements/requirements-tests.in + pip-compile --upgrade requirements/requirements-docs.in } function Invoke-Lint { pre-commit run --all-files - python -m black . python -m ruff --fix . + python -m ruff format . + python -m mypy --strict src/ } function Invoke-Test diff --git a/pyproject.toml b/pyproject.toml index 5921406..736c9c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,15 +19,15 @@ dev = { file = ["requirements/requirements-dev.txt"] } tests = { file = ["requirements/requirements-tests.txt"] } docs = { file = ["requirements/requirements-docs.txt"] } -[tool.black] -target-version = ["py311"] -line-length = 120 - [tool.ruff] +preview = true +unsafe-fixes = true target-version = "py311" -line-length = 120 + +[tool.ruff.lint] select = ["ALL"] ignore = [ + "CPY001", # (Missing copyright notice at top of file) "ERA001", # (Found commented-out code) - Porting features a piece at a time "G004", # (Logging statement uses f-string) - Developer UX "S311", # (Standard pseudo-random generators are not suitable for cryptographic purposes) - all false positives @@ -39,7 +39,7 @@ ignore = [ "PLR2004", # (Magic value used in comparison, consider replacing `` with a constant variable) - Be responsible ] -[tool.ruff.extend-per-file-ignores] +[tool.ruff.lint.extend-per-file-ignores] "docs/*" = [ "INP001", # (File `tests/*.py` is part of an implicit namespace package. Add an `__init__.py`.) - Docs are not modules ] @@ -48,6 +48,5 @@ ignore = [ "S101", # (Use of `assert` detected) - Yes, that's the point ] - -[tool.ruff.pydocstyle] +[tool.ruff.lint.pydocstyle] convention = "numpy" diff --git a/requirements/requirements-dev.in b/requirements/requirements-dev.in index a30e571..4cb96ee 100644 --- a/requirements/requirements-dev.in +++ b/requirements/requirements-dev.in @@ -3,5 +3,5 @@ pip-tools pre-commit -black ruff +mypy diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt index d93366d..f664f0e 100644 --- a/requirements/requirements-dev.txt +++ b/requirements/requirements-dev.txt @@ -2,53 +2,51 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --output-file=requirements/requirements-dev.txt requirements/requirements-dev.in +# pip-compile requirements/requirements-dev.in # -black==23.9.1 - # via -r requirements/requirements-dev.in build==1.0.3 # via pip-tools cfgv==3.4.0 # via pre-commit click==8.1.7 - # via - # black - # pip-tools -distlib==0.3.7 + # via pip-tools +distlib==0.3.8 # via virtualenv -filelock==3.12.4 +filelock==3.13.1 # via # -c requirements/requirements.txt # virtualenv -identify==2.5.30 +identify==2.5.34 # via pre-commit +mypy==1.8.0 + # via -r requirements/requirements-dev.in mypy-extensions==1.0.0 - # via black + # via mypy nodeenv==1.8.0 # via pre-commit packaging==23.2 - # via - # black - # build -pathspec==0.11.2 - # via black -pip-tools==7.3.0 + # via build +pip-tools==7.4.0 # via -r requirements/requirements-dev.in -platformdirs==3.11.0 - # via - # black - # virtualenv -pre-commit==3.5.0 +platformdirs==4.2.0 + # via virtualenv +pre-commit==3.6.1 # via -r requirements/requirements-dev.in pyproject-hooks==1.0.0 - # via build + # via + # build + # pip-tools pyyaml==6.0.1 # via pre-commit -ruff==0.0.292 +ruff==0.2.2 # via -r requirements/requirements-dev.in -virtualenv==20.24.5 +typing-extensions==4.9.0 + # via + # -c requirements/requirements.txt + # mypy +virtualenv==20.25.0 # via pre-commit -wheel==0.41.2 +wheel==0.42.0 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/requirements-docs.txt b/requirements/requirements-docs.txt index 4441f91..6e5a0bc 100644 --- a/requirements/requirements-docs.txt +++ b/requirements/requirements-docs.txt @@ -2,45 +2,45 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --output-file=requirements/requirements-docs.txt requirements/requirements-docs.in +# pip-compile requirements/requirements-docs.in # -alabaster==0.7.13 +alabaster==0.7.16 # via sphinx anyascii==0.3.2 # via sphinx-autoapi -astroid==3.0.0 +astroid==3.0.3 # via sphinx-autoapi -babel==2.13.0 +babel==2.14.0 # via sphinx -beautifulsoup4==4.12.2 +beautifulsoup4==4.12.3 # via furo -certifi==2023.7.22 +certifi==2024.2.2 # via # -c requirements/requirements.txt # requests -charset-normalizer==3.3.0 +charset-normalizer==3.3.2 # via # -c requirements/requirements.txt # requests docutils==0.20.1 # via sphinx -furo==2023.9.10 +furo==2024.1.29 # via -r requirements/requirements-docs.in -idna==3.4 +idna==3.6 # via # -c requirements/requirements.txt # requests imagesize==1.4.1 # via sphinx -jinja2==3.1.2 +jinja2==3.1.3 # via # sphinx # sphinx-autoapi -markupsafe==2.1.3 +markupsafe==2.1.5 # via jinja2 packaging==23.2 # via sphinx -pygments==2.16.1 +pygments==2.17.2 # via # furo # sphinx @@ -65,28 +65,23 @@ sphinx==7.2.6 # releases # sphinx-autoapi # sphinx-basic-ng - # sphinxcontrib-applehelp - # sphinxcontrib-devhelp - # sphinxcontrib-htmlhelp - # sphinxcontrib-qthelp - # sphinxcontrib-serializinghtml sphinx-autoapi==3.0.0 # via -r requirements/requirements-docs.in sphinx-basic-ng==1.0.0b2 # via furo -sphinxcontrib-applehelp==1.0.7 +sphinxcontrib-applehelp==1.0.8 # via sphinx -sphinxcontrib-devhelp==1.0.5 +sphinxcontrib-devhelp==1.0.6 # via sphinx -sphinxcontrib-htmlhelp==2.0.4 +sphinxcontrib-htmlhelp==2.0.5 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.6 +sphinxcontrib-qthelp==1.0.7 # via sphinx -sphinxcontrib-serializinghtml==1.1.9 +sphinxcontrib-serializinghtml==1.1.10 # via sphinx -urllib3==2.0.6 +urllib3==2.2.1 # via # -c requirements/requirements.txt # requests diff --git a/requirements/requirements-tests.txt b/requirements/requirements-tests.txt index 9917983..a40f56e 100644 --- a/requirements/requirements-tests.txt +++ b/requirements/requirements-tests.txt @@ -2,13 +2,17 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --output-file=requirements/requirements-tests.txt requirements/requirements-tests.in +# pip-compile requirements/requirements-tests.in # iniconfig==2.0.0 # via pytest packaging==23.2 # via pytest -pluggy==1.3.0 +pluggy==1.4.0 # via pytest -pytest==7.4.2 +pytest==8.0.1 + # via + # -r requirements/requirements-tests.in + # pytest-randomly +pytest-randomly==3.15.0 # via -r requirements/requirements-tests.in diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 8ca6218..52b862c 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -2,11 +2,11 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --output-file=requirements/requirements.txt requirements/requirements.in +# pip-compile requirements/requirements.in # -aiodns==3.0.0 +aiodns==3.1.1 # via pydis-core -aiohttp==3.8.6 +aiohttp==3.9.3 # via # -r requirements/requirements.in # discord-py @@ -16,104 +16,98 @@ annotated-types==0.6.0 # via pydantic arrow==1.3.0 # via -r requirements/requirements.in -async-timeout==4.0.3 +attrs==23.2.0 # via aiohttp -attrs==23.1.0 - # via aiohttp -certifi==2023.7.22 +certifi==2024.2.2 # via # requests # sentry-sdk cffi==1.16.0 # via pycares -charset-normalizer==3.3.0 - # via - # aiohttp - # requests +charset-normalizer==3.3.2 + # via requests coloredlogs==15.0.1 # via -r requirements/requirements.in discord-py==2.3.2 # via # -r requirements/requirements.in # pydis-core -filelock==3.12.4 +filelock==3.13.1 # via tldextract -frozenlist==1.4.0 +frozenlist==1.4.1 # via # aiohttp # aiosignal -greenlet==3.0.0 +greenlet==3.0.3 # via sqlalchemy humanfriendly==10.0 # via coloredlogs -idna==3.4 +idna==3.6 # via # requests # tldextract # yarl imsosorry==1.2.1 # via -r requirements/requirements.in -multidict==6.0.4 +multidict==6.0.5 # via # aiohttp # yarl -psycopg[binary]==3.1.12 +psycopg[binary]==3.1.18 # via -r requirements/requirements.in -psycopg-binary==3.1.12 +psycopg-binary==3.1.18 # via psycopg pycares==4.4.0 # via aiodns pycparser==2.21 # via cffi -pydantic==2.4.2 +pydantic==2.6.1 # via # pydantic-settings # pydis-core -pydantic-core==2.10.1 +pydantic-core==2.16.2 # via pydantic -pydantic-settings==2.0.3 +pydantic-settings==2.2.0 # via -r requirements/requirements.in -pydis-core==10.3.0 +pydis-core==10.7.0 # via -r requirements/requirements.in python-dateutil==2.8.2 # via arrow -python-dotenv==1.0.0 +python-dotenv==1.0.1 # via pydantic-settings -rapidfuzz==3.4.0 +rapidfuzz==3.6.1 # via -r requirements/requirements.in -regex==2023.10.3 +regex==2023.12.25 # via -r requirements/requirements.in requests==2.31.0 # via # requests-file # tldextract -requests-file==1.5.1 +requests-file==2.0.0 # via tldextract -sentry-sdk==1.32.0 +sentry-sdk==1.40.4 # via -r requirements/requirements.in six==1.16.0 - # via - # python-dateutil - # requests-file -sqlalchemy==2.0.22 + # via python-dateutil +sqlalchemy==2.0.27 # via -r requirements/requirements.in statsd==4.0.1 # via pydis-core -tldextract==5.0.0 +tldextract==5.1.1 # via -r requirements/requirements.in -types-python-dateutil==2.8.19.14 +types-python-dateutil==2.8.19.20240106 # via arrow -typing-extensions==4.8.0 +typing-extensions==4.9.0 # via # psycopg # pydantic # pydantic-core # sqlalchemy -urllib3==2.0.6 +urllib3==2.2.1 # via # requests # sentry-sdk wonderwords==2.2.0 # via -r requirements/requirements.in -yarl==1.9.2 +yarl==1.9.4 # via aiohttp diff --git a/src/bot/constants.py b/src/bot/constants.py index 38ebf62..07fe936 100644 --- a/src/bot/constants.py +++ b/src/bot/constants.py @@ -94,7 +94,7 @@ class _Roles(EnvConfig, env_prefix="roles_"): class _Guild(EnvConfig, env_prefix="guild_"): """Guild constants.""" - id: int = 1033456860864466995 # noqa: A003 - variable is nested + id: int = 1033456860864466995 # - variable is nested moderation_roles: tuple[int, ...] = (Roles.administrators, Roles.moderators) staff_roles: tuple[int, ...] = (Roles.administrators, Roles.moderators, Roles.staff) @@ -150,14 +150,14 @@ class _Tokens(EnvConfig, env_prefix="tokens_"): class _Emojis(EnvConfig, env_prefix="emojis_"): """Named emoji constants.""" - cross_mark: str = "\u274C" - star: str = "\u2B50" - christmas_tree: str = "\U0001F384" + cross_mark: str = "\u274c" + star: str = "\u2b50" + christmas_tree: str = "\U0001f384" check: str = "\u2611" - envelope: str = "\U0001F4E8" + envelope: str = "\U0001f4e8" trashcan: str = "<:trashcan:637136429717389331>" ok_hand: str = ":ok_hand:" - hand_raised: str = "\U0001F64B" + hand_raised: str = "\U0001f64b" dice_1: str = "<:dice_1:755891608859443290>" dice_2: str = "<:dice_2:755891608741740635>" @@ -176,7 +176,7 @@ class _Emojis(EnvConfig, env_prefix="emojis_"): pull_request_draft: str = "<:PRDraft:852596025045680218>" pull_request_merged: str = "<:PRMerged:852596100301193227>" - number_emojis: dict[int, str] = { # noqa: RUF012 - uh... + number_emojis: dict[int, str] = { # - uh... 1: "\u0031\ufe0f\u20e3", 2: "\u0032\ufe0f\u20e3", 3: "\u0033\ufe0f\u20e3", @@ -236,19 +236,17 @@ class _Icons(EnvConfig, env_prefix="icons_"): filtering: str = "https://cdn.discordapp.com/emojis/472472638594482195.png" - green_checkmark: str = ( - "https://raw.githubusercontent.com/python-discord/branding/main/icons/checkmark/green-checkmark-dist.png" - ) - green_questionmark: str = ( - "https://raw.githubusercontent.com/python-discord/branding/main/icons/checkmark/green-question-mark-dist.png" - ) + green_checkmark: str = "https://raw.githubusercontent.com/python-discord/branding/main/icons/checkmark/green-checkmark-dist.png" + green_questionmark: str = "https://raw.githubusercontent.com/python-discord/branding/main/icons/checkmark/green-question-mark-dist.png" guild_update: str = "https://cdn.discordapp.com/emojis/469954765141442561.png" hash_blurple: str = "https://cdn.discordapp.com/emojis/469950142942806017.png" hash_green: str = "https://cdn.discordapp.com/emojis/469950144918585344.png" hash_red: str = "https://cdn.discordapp.com/emojis/469950145413251072.png" - message_bulk_delete: str = "https://cdn.discordapp.com/emojis/469952898994929668.png" + message_bulk_delete: str = ( + "https://cdn.discordapp.com/emojis/469952898994929668.png" + ) message_delete: str = "https://cdn.discordapp.com/emojis/472472641320648704.png" message_edit: str = "https://cdn.discordapp.com/emojis/472472638976163870.png" @@ -266,7 +264,9 @@ class _Icons(EnvConfig, env_prefix="icons_"): superstarify: str = "https://cdn.discordapp.com/emojis/636288153044516874.png" unsuperstarify: str = "https://cdn.discordapp.com/emojis/636288201258172446.png" - token_removed: str = "https://cdn.discordapp.com/emojis/470326273298792469.png" # - false positive + token_removed: str = ( + "https://cdn.discordapp.com/emojis/470326273298792469.png" # - false positive + ) user_ban: str = "https://cdn.discordapp.com/emojis/469952898026045441.png" user_timeout: str = "https://cdn.discordapp.com/emojis/472472640100106250.png" diff --git a/src/bot/exts/core/error_handler.py b/src/bot/exts/core/error_handler.py index 9cd3364..ce26d17 100644 --- a/src/bot/exts/core/error_handler.py +++ b/src/bot/exts/core/error_handler.py @@ -32,7 +32,9 @@ def revert_cooldown_counter(command: commands.Command, message: Message) -> None if command._buckets.valid: bucket = command._buckets.get_bucket(message) bucket._tokens = min(bucket.rate, bucket._tokens + 1) - logging.debug("Cooldown counter reverted as the command was not used correctly.") + logging.debug( + "Cooldown counter reverted as the command was not used correctly." + ) @staticmethod def error_embed(message: str, title: Iterable | str = NEGATIVE_REPLIES) -> Embed: @@ -53,7 +55,9 @@ async def on_command_error( # noqa: PLR0911 - uh... ) -> None: """Activates when a command raises an error.""" if getattr(error, "handled", False): - logging.debug(f"Command {ctx.command} had its error already handled locally; ignoring.") + logging.debug( + f"Command {ctx.command} had its error already handled locally; ignoring." + ) return parent_command = "" @@ -76,7 +80,9 @@ async def on_command_error( # noqa: PLR0911 - uh... if isinstance(error, commands.UserInputError): self.revert_cooldown_counter(ctx.command, ctx.message) usage = f"```\n{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}\n```" - embed = self.error_embed(f"Your input was invalid: {error}\n\nUsage:{usage}") + embed = self.error_embed( + f"Your input was invalid: {error}\n\nUsage:{usage}" + ) await ctx.send(embed=embed) return @@ -90,11 +96,19 @@ async def on_command_error( # noqa: PLR0911 - uh... return if isinstance(error, commands.DisabledCommand): - await ctx.send(embed=self.error_embed("This command has been disabled.", NEGATIVE_REPLIES)) + await ctx.send( + embed=self.error_embed( + "This command has been disabled.", NEGATIVE_REPLIES + ) + ) return if isinstance(error, commands.NoPrivateMessage): - await ctx.send(embed=self.error_embed("This command can only be used in the server. ", NEGATIVE_REPLIES)) + await ctx.send( + embed=self.error_embed( + "This command can only be used in the server. ", NEGATIVE_REPLIES + ) + ) return if isinstance(error, commands.BadArgument): @@ -107,7 +121,11 @@ async def on_command_error( # noqa: PLR0911 - uh... return if isinstance(error, commands.CheckFailure): - await ctx.send(embed=self.error_embed("You are not authorized to use this command.", NEGATIVE_REPLIES)) + await ctx.send( + embed=self.error_embed( + "You are not authorized to use this command.", NEGATIVE_REPLIES + ) + ) return if isinstance(error, APIError): @@ -128,7 +146,9 @@ async def on_command_error( # noqa: PLR0911 - uh... return if isinstance(error, commands.MaxConcurrencyReached): - embed = self.error_embed("This command can only be used 1 time per channel concurrently.") + embed = self.error_embed( + "This command can only be used 1 time per channel concurrently." + ) await ctx.send(embed=embed) return @@ -146,17 +166,23 @@ async def on_command_error( # noqa: PLR0911 - uh... log.exception(f"Unhandled command error: {error!s}", exc_info=error) - async def send_command_suggestion(self: Self, ctx: commands.Context, command_name: str) -> None: + async def send_command_suggestion( + self: Self, ctx: commands.Context, command_name: str + ) -> None: """Send user similar commands if any can be found.""" command_suggestions = [] - if similar_command_names := get_command_suggestions(list(self.bot.all_commands.keys()), command_name): + if similar_command_names := get_command_suggestions( + list(self.bot.all_commands.keys()), command_name + ): for similar_command_name in similar_command_names: similar_command = self.bot.get_command(similar_command_name) if not similar_command: continue - log_msg = "Cancelling attempt to suggest a command due to failed checks." + log_msg = ( + "Cancelling attempt to suggest a command due to failed checks." + ) try: if not await similar_command.can_run(ctx): log.debug(log_msg) @@ -171,7 +197,8 @@ async def send_command_suggestion(self: Self, ctx: commands.Context, command_nam embed = Embed() embed.set_author(name="Did you mean:", icon_url=QUESTION_MARK_ICON) embed.description = "\n".join( - misspelled_content.replace(command_name, cmd, 1) for cmd in command_suggestions + misspelled_content.replace(command_name, cmd, 1) + for cmd in command_suggestions ) await ctx.send(embed=embed, delete_after=7.5) diff --git a/src/bot/exts/core/log.py b/src/bot/exts/core/log.py index 3cdb2af..43e5fa4 100644 --- a/src/bot/exts/core/log.py +++ b/src/bot/exts/core/log.py @@ -36,7 +36,9 @@ async def send_log_message( ) -> Context: """Generate log embed and send to logging channel.""" # Truncate string directly here to avoid removing newlines - embed = discord.Embed(description=text[:4093] + "..." if len(text) > 4096 else text) + embed = discord.Embed( + description=text[:4093] + "..." if len(text) > 4096 else text + ) if title and icon_url: embed.set_author(name=title, icon_url=icon_url) @@ -51,7 +53,11 @@ async def send_log_message( embed.set_thumbnail(url=thumbnail) if ping_mods: - content = f"<@&{Roles.moderators}> {content}" if content else f"<@&{Roles.moderators}>" + content = ( + f"<@&{Roles.moderators}> {content}" + if content + else f"<@&{Roles.moderators}>" + ) # Truncate content to 2000 characters and append an ellipsis. if content and len(content) > 2000: @@ -64,7 +70,9 @@ async def send_log_message( for additional_embed in additional_embeds: await channel.send(embed=additional_embed) - return await self.bot.get_context(log_message) # Optionally return for use with antispam + return await self.bot.get_context( + log_message + ) # Optionally return for use with antispam async def setup(bot: Bot) -> None: diff --git a/src/bot/exts/filters/webhook_remover.py b/src/bot/exts/filters/webhook_remover.py index 285bafb..eefdc36 100644 --- a/src/bot/exts/filters/webhook_remover.py +++ b/src/bot/exts/filters/webhook_remover.py @@ -41,7 +41,9 @@ def log(self: Self) -> Log | None: """Get current instance of `Log`.""" return self.bot.get_cog("Log") - async def delete_and_respond(self: Self, message: Message, matches: Match[str]) -> None: + async def delete_and_respond( + self: Self, message: Message, matches: Match[str] + ) -> None: """Delete `message` and send a warning that it contained a Discord webhook.""" webhook_url = matches[0] redacted_url = matches[1] + "xxx" @@ -68,11 +70,15 @@ async def delete_and_respond(self: Self, message: Message, matches: Match[str]) try: await message.delete() except NotFound: - log.debug(f"Failed to remove webhook in message {message.id}: message already deleted.") + log.debug( + f"Failed to remove webhook in message {message.id}: message already deleted." + ) return # Log to user - await message.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=message.author.mention)) + await message.channel.send( + ALERT_MESSAGE_TEMPLATE.format(user=message.author.mention) + ) if deleted_successfully: delete_state = "The webhook was successfully deleted." diff --git a/src/bot/exts/fun/dice.py b/src/bot/exts/fun/dice.py index 11fb318..b828696 100644 --- a/src/bot/exts/fun/dice.py +++ b/src/bot/exts/fun/dice.py @@ -17,9 +17,16 @@ def __init__(self: Self, bot: Bot) -> None: self.bot = bot @app_commands.command(name="roll") - async def roll(self: Self, interaction: discord.Interaction, number_of_dice: int, number_of_sides: int) -> None: + async def roll( + self: Self, + interaction: discord.Interaction, + number_of_dice: int, + number_of_sides: int, + ) -> None: """Roll dice.""" - rolls = ", ".join([str(randint(1, number_of_sides)) for _ in range(number_of_dice)]) + rolls = ", ".join([ + str(randint(1, number_of_sides)) for _ in range(number_of_dice) + ]) await interaction.response.send_message(rolls) diff --git a/src/bot/exts/fun/typeracer.py b/src/bot/exts/fun/typeracer.py index aeefe04..68c21eb 100644 --- a/src/bot/exts/fun/typeracer.py +++ b/src/bot/exts/fun/typeracer.py @@ -42,8 +42,12 @@ def game_over(self: Self) -> bool: def word_embed(self: Self) -> Embed: """Build the word embed.""" - embed = Embed(title="The word is:", description=f"`{self._word_display()}`", colour=Colour.yellow()) - embed.set_footer(text=f"{self.i+1}/{len(self.word_list)}") + embed = Embed( + title="The word is:", + description=f"`{self._word_display()}`", + colour=Colour.yellow(), + ) + embed.set_footer(text=f"{self.i + 1}/{len(self.word_list)}") return embed def is_correct(self: Self, answer: str) -> bool: @@ -57,14 +61,19 @@ def is_copypaste(self: Self, answer: str) -> bool: def check_message(self: Self, message: Message) -> bool: """Check function for processing valid inputs.""" return ( - message.content == self.word_list[self.i] or message.content == self._word_display() + message.content == self.word_list[self.i] + or message.content == self._word_display() ) and message.channel == self.ctx.channel def scoreboard_embed(self: Self) -> Embed: """Build the scoreboard embed.""" embed = Embed(title="Final Scoreboard", colour=Colour.blue()) - scoreboard_list = [(self.players[user_id], self.scores[user_id]) for user_id in self.players] - scoreboard_list = sorted(scoreboard_list, key=lambda pair: pair[1], reverse=True) + scoreboard_list = [ + (self.players[user_id], self.scores[user_id]) for user_id in self.players + ] + scoreboard_list = sorted( + scoreboard_list, key=lambda pair: pair[1], reverse=True + ) prev = None offset = 0 for i, pair in enumerate(scoreboard_list): @@ -75,7 +84,11 @@ def scoreboard_embed(self: Self) -> Embed: else: offset = 0 prev = score - embed.add_field(name=f"{i+1-offset}. {username}", value=f"**{score} words**", inline=False) + embed.add_field( + name=f"{i + 1 - offset}. {username}", + value=f"**{score} words**", + inline=False, + ) return embed def process_correct_answer(self: Self, message: Message) -> Embed: @@ -83,9 +96,13 @@ def process_correct_answer(self: Self, message: Message) -> Embed: user_id = message.author.id username = message.author.name icon_url = message.author.avatar.url - embed = Embed(title="The word was:", description=self.word_list[self.i], colour=Colour.green()) + embed = Embed( + title="The word was:", + description=self.word_list[self.i], + colour=Colour.green(), + ) embed.set_author(name=f"{username} got it right!", icon_url=icon_url) - embed.set_footer(text=f"{self.i+1}/{len(self.word_list)}") + embed.set_footer(text=f"{self.i + 1}/{len(self.word_list)}") self._update_scoreboard(user_id, username) self._next_word() return embed @@ -118,9 +135,13 @@ async def typeracer(self: Self, ctx: Context, number_of_words: int = 5) -> None: message = await ctx.send(embed=embed) try: # only process messages that satisfy the check function - answer = await self.bot.wait_for("message", check=race.check_message, timeout=30) + answer = await self.bot.wait_for( + "message", check=race.check_message, timeout=30 + ) except TimeoutError: - await message.edit(content=message.content + "\n\nThe game has timed out!") + await message.edit( + content=message.content + "\n\nThe game has timed out!" + ) # display scoreboard if game times out if race.scores: await ctx.send(embed=race.scoreboard_embed()) diff --git a/src/bot/exts/info/code_snippets.py b/src/bot/exts/info/code_snippets.py index f26979c..d55d16f 100644 --- a/src/bot/exts/info/code_snippets.py +++ b/src/bot/exts/info/code_snippets.py @@ -61,9 +61,13 @@ def __init__(self: Self, bot: Bot) -> None: (BITBUCKET_RE, self._fetch_bitbucket_snippet), ] - async def _fetch_response(self: Self, url: str, response_format: str, **kwargs: dict) -> str | dict | None: + async def _fetch_response( + self: Self, url: str, response_format: str, **kwargs: dict + ) -> str | dict | None: """Make http requests using aiohttp.""" - async with self.bot.http_session.get(url, raise_for_status=True, **kwargs) as response: + async with self.bot.http_session.get( + url, raise_for_status=True, **kwargs + ) as response: if response_format == "text": return await response.text() if response_format == "json": @@ -82,7 +86,9 @@ def _find_ref(self: Self, path: str, refs: tuple) -> tuple: break return ref, file_path - async def _fetch_github_snippet(self: Self, repo: str, path: str, start_line: str, end_line: str) -> str: + async def _fetch_github_snippet( + self: Self, repo: str, path: str, start_line: str, end_line: str + ) -> str: """Fetch a snippet from a GitHub repo.""" # Search the GitHub API for the specified branch branches = await self._fetch_response( @@ -90,7 +96,9 @@ async def _fetch_github_snippet(self: Self, repo: str, path: str, start_line: st "json", headers=GITHUB_HEADERS, ) - tags = await self._fetch_response(f"https://api.github.com/repos/{repo}/tags", "json", headers=GITHUB_HEADERS) + tags = await self._fetch_response( + f"https://api.github.com/repos/{repo}/tags", "json", headers=GITHUB_HEADERS + ) refs = branches + tags ref, file_path = self._find_ref(path, refs) @@ -99,7 +107,9 @@ async def _fetch_github_snippet(self: Self, repo: str, path: str, start_line: st "text", headers=GITHUB_HEADERS, ) - return self._snippet_to_codeblock(file_contents, file_path, start_line, end_line) + return self._snippet_to_codeblock( + file_contents, file_path, start_line, end_line + ) async def _fetch_github_gist_snippet( self: Self, @@ -123,10 +133,14 @@ async def _fetch_github_gist_snippet( gist_json["files"][gist_file]["raw_url"], "text", ) - return self._snippet_to_codeblock(file_contents, gist_file, start_line, end_line) + return self._snippet_to_codeblock( + file_contents, gist_file, start_line, end_line + ) return "" - async def _fetch_gitlab_snippet(self: Self, repo: str, path: str, start_line: str, end_line: str) -> str: + async def _fetch_gitlab_snippet( + self: Self, repo: str, path: str, start_line: str, end_line: str + ) -> str: """Fetch a snippet from a GitLab repo.""" enc_repo = quote_plus(repo) @@ -135,7 +149,9 @@ async def _fetch_gitlab_snippet(self: Self, repo: str, path: str, start_line: st f"https://gitlab.com/api/v4/projects/{enc_repo}/repository/branches", "json", ) - tags = await self._fetch_response(f"https://gitlab.com/api/v4/projects/{enc_repo}/repository/tags", "json") + tags = await self._fetch_response( + f"https://gitlab.com/api/v4/projects/{enc_repo}/repository/tags", "json" + ) refs = branches + tags ref, file_path = self._find_ref(path, refs) enc_ref = quote_plus(ref) @@ -145,7 +161,9 @@ async def _fetch_gitlab_snippet(self: Self, repo: str, path: str, start_line: st f"https://gitlab.com/api/v4/projects/{enc_repo}/repository/files/{enc_file_path}/raw?ref={enc_ref}", "text", ) - return self._snippet_to_codeblock(file_contents, file_path, start_line, end_line) + return self._snippet_to_codeblock( + file_contents, file_path, start_line, end_line + ) async def _fetch_bitbucket_snippet( self: Self, @@ -160,9 +178,13 @@ async def _fetch_bitbucket_snippet( f"https://bitbucket.org/{quote_plus(repo)}/raw/{quote_plus(ref)}/{quote_plus(file_path)}", "text", ) - return self._snippet_to_codeblock(file_contents, file_path, start_line, end_line) + return self._snippet_to_codeblock( + file_contents, file_path, start_line, end_line + ) - def _snippet_to_codeblock(self: Self, file_contents: str, file_path: str, start_line: str, end_line: str) -> str: + def _snippet_to_codeblock( + self: Self, file_contents: str, file_path: str, start_line: str, end_line: str + ) -> str: """ Given the entire file contents and target lines, creates a code block. @@ -225,7 +247,9 @@ async def _parse_snippets(self: Self, content: str) -> str: except ClientResponseError as error: error_message = error.message log.log( - logging.DEBUG if error.status == HTTPStatus.NOT_FOUND else logging.ERROR, + logging.DEBUG + if error.status == HTTPStatus.NOT_FOUND + else logging.ERROR, f"Failed to fetch code snippet from {match[0]!r}: {error.status} " f"{error_message} for GET {error.request_info.real_url.human_repr()}", ) diff --git a/src/bot/exts/info/github.py b/src/bot/exts/info/github.py index ee2b8fb..5539503 100644 --- a/src/bot/exts/info/github.py +++ b/src/bot/exts/info/github.py @@ -81,7 +81,9 @@ def remove_codeblocks(message: str) -> str: """Remove any codeblock in a message.""" return CODE_BLOCK_RE.sub("", message) - async def fetch_issue(self: Self, number: int, repository: str, user: str) -> IssueState | FetchError: + async def fetch_issue( + self: Self, number: int, repository: str, user: str + ) -> IssueState | FetchError: """ Retrieve an issue from a GitHub repository. @@ -95,9 +97,12 @@ async def fetch_issue(self: Self, number: int, repository: str, user: str) -> Is if response.status == HTTPStatus.FORBIDDEN: if response.headers.get("X-RateLimit-Remaining") == "0": log.info(f"Ratelimit reached while fetching {url}") - return FetchError(HTTPStatus.FORBIDDEN, "Ratelimit reached, please retry in a few minutes.") + return FetchError( + HTTPStatus.FORBIDDEN, + "Ratelimit reached, please retry in a few minutes.", + ) return FetchError(HTTPStatus.FORBIDDEN, "Cannot access issue.") - if response.status in (HTTPStatus.NOT_FOUND, HTTPStatus.GONE): + if response.status in {HTTPStatus.NOT_FOUND, HTTPStatus.GONE}: return FetchError(response.status, "Issue not found.") if response.status != HTTPStatus.OK: return FetchError(response.status, "Error while fetching issue.") @@ -107,7 +112,11 @@ async def fetch_issue(self: Self, number: int, repository: str, user: str) -> Is # from issues: if the 'issues' key is present in the response then we can pull the data we # need from the initial API call. if "issues" in json_data["html_url"]: - emoji = Emojis.issue_open if json_data.get("state") == "open" else Emojis.issue_closed + emoji = ( + Emojis.issue_open + if json_data.get("state") == "open" + else Emojis.issue_closed + ) # If the 'issues' key is not contained in the API response and there is no error code, then # we know that a PR has been requested and a call to the pulls API endpoint is necessary @@ -126,7 +135,9 @@ async def fetch_issue(self: Self, number: int, repository: str, user: str) -> Is issue_url = json_data.get("html_url") - return IssueState(repository, number, issue_url, json_data.get("title", ""), emoji) + return IssueState( + repository, number, issue_url, json_data.get("title", ""), emoji + ) @staticmethod def format_embed(results: list[IssueState | FetchError]) -> discord.Embed: @@ -141,7 +152,9 @@ def format_embed(results: list[IssueState | FetchError]) -> discord.Embed: elif isinstance(result, FetchError): description_list.append(f":x: [{result.return_code}] {result.message}") - resp = discord.Embed(colour=Colours.bright_green, description="\n".join(description_list)) + resp = discord.Embed( + colour=Colours.bright_green, description="\n".join(description_list) + ) resp.set_author(name="GitHub") return resp @@ -166,7 +179,9 @@ async def on_message(self: Self, message: discord.Message) -> None: issues = [ FoundIssue(*match.group("org", "repo", "number")) - for match in AUTOMATIC_REGEX.finditer(self.remove_codeblocks(message.content)) + for match in AUTOMATIC_REGEX.finditer( + self.remove_codeblocks(message.content) + ) ] links = [] @@ -210,7 +225,9 @@ async def fetch_data(self: Self, url: str) -> tuple[dict[str], ClientResponse]: return await response.json(), response @github_group.command(name="user", aliases=("userinfo",)) - async def github_user_info(self: Self, ctx: commands.Context, username: str) -> None: + async def github_user_info( + self: Self, ctx: commands.Context, username: str + ) -> None: """Fetch a user's GitHub information.""" async with ctx.typing(): user_data, _ = await self.fetch_data(f"{GITHUB_API_URL}/users/{username}") @@ -227,7 +244,10 @@ async def github_user_info(self: Self, ctx: commands.Context, username: str) -> return org_data, _ = await self.fetch_data(user_data["organizations_url"]) - orgs = [f"[{org['login']}](https://github.com/{org['login']})" for org in org_data] + orgs = [ + f"[{org['login']}](https://github.com/{org['login']})" + for org in org_data + ] orgs_to_add = " | ".join(orgs) gists = user_data["public_gists"] @@ -242,10 +262,14 @@ async def github_user_info(self: Self, ctx: commands.Context, username: str) -> embed = discord.Embed( title=f"`{user_data['login']}`'s GitHub profile info", - description=f"```\n{user_data['bio']}\n```\n" if user_data["bio"] else "", + description=f"```\n{user_data['bio']}\n```\n" + if user_data["bio"] + else "", colour=discord.Colour.og_blurple(), url=user_data["html_url"], - timestamp=datetime.strptime(user_data["created_at"], "%Y-%m-%dT%H:%M:%SZ"), + timestamp=datetime.strptime( + user_data["created_at"], "%Y-%m-%dT%H:%M:%SZ" + ), ) embed.set_thumbnail(url=user_data["avatar_url"]) embed.set_footer(text="Account created at") @@ -298,7 +322,9 @@ async def github_repo_info(self: Self, ctx: commands.Context, *repo: str) -> Non return async with ctx.typing(): - repo_data, _ = await self.fetch_data(f"{GITHUB_API_URL}/repos/{quote(repo)}") + repo_data, _ = await self.fetch_data( + f"{GITHUB_API_URL}/repos/{quote(repo)}" + ) # There won't be a message key if this repo exists if "message" in repo_data: @@ -321,7 +347,9 @@ async def github_repo_info(self: Self, ctx: commands.Context, *repo: str) -> Non # If it's a fork, then it will have a parent key try: parent = repo_data["parent"] - embed.description += f"\n\nForked from [{parent['full_name']}]({parent['html_url']})" + embed.description += ( + f"\n\nForked from [{parent['full_name']}]({parent['html_url']})" + ) except KeyError: log.debug("Repository is not a fork.") @@ -333,8 +361,12 @@ async def github_repo_info(self: Self, ctx: commands.Context, *repo: str) -> Non icon_url=repo_owner["avatar_url"], ) - repo_created_at = datetime.strptime(repo_data["created_at"], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y") - last_pushed = datetime.strptime(repo_data["pushed_at"], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y at %H:%M") + repo_created_at = datetime.strptime( + repo_data["created_at"], "%Y-%m-%dT%H:%M:%SZ" + ).strftime("%d/%m/%Y") + last_pushed = datetime.strptime( + repo_data["pushed_at"], "%Y-%m-%dT%H:%M:%SZ" + ).strftime("%d/%m/%Y at %H:%M") embed.set_footer( text=( diff --git a/src/bot/exts/utilities/internal.py b/src/bot/exts/utilities/internal.py index 8f9280c..1990618 100644 --- a/src/bot/exts/utilities/internal.py +++ b/src/bot/exts/utilities/internal.py @@ -70,7 +70,7 @@ def _format( # noqa: PLR0912 - double check this # Create the input dialog for i, line in enumerate(lines): - if i == 0: # noqa: SIM108 - spread out for docs + if i == 0: # - spread out for docs # Start dialog start = f"In [{self.ln}]: " @@ -119,11 +119,17 @@ def _format( # noqa: PLR0912 - double check this res = (res, out) else: - if isinstance(out, str) and out.startswith("Traceback (most recent call last):\n"): + if isinstance(out, str) and out.startswith( + "Traceback (most recent call last):\n" + ): # Leave out the traceback message out = "\n" + "\n".join(out.split("\n")[1:]) - pretty = out if isinstance(out, str) else pprint.pformat(out, compact=True, width=60) + pretty = ( + out + if isinstance(out, str) + else pprint.pformat(out, compact=True, width=60) + ) if pretty != str(out): # We're using the pretty version, start on the next line @@ -206,7 +212,9 @@ async def func(): # (None,) -> Any if len(out) > truncate_index: try: - paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") + paste_link = await send_to_paste_service( + self.bot.http_session, out, extension="py" + ) except PasteTooLongError: paste_text = "too long to upload to paste service." except PasteUploadError: @@ -214,7 +222,10 @@ async def func(): # (None,) -> Any else: paste_text = f"full contents at {paste_link}" - await ctx.send(f"```py\n{out[:truncate_index]}\n```... response truncated; {paste_text}", embed=embed) + await ctx.send( + f"```py\n{out[:truncate_index]}\n```... response truncated; {paste_text}", + embed=embed, + ) return None await ctx.send(f"```py\n{out}```", embed=embed) @@ -229,7 +240,9 @@ async def internal_group(self: Self, ctx: Context) -> None: @internal_group.command(name="eval", aliases=("e",)) @has_any_role(Roles.administrators) - async def eval(self: Self, ctx: Context, *, code: str) -> None: # noqa: A003 - uh... good point + async def eval( + self: Self, ctx: Context, *, code: str + ) -> None: # - uh... good point """Run eval in a REPL-like format.""" code = code.strip("`") if re.match("py(thon)?\n", code): @@ -239,7 +252,7 @@ async def eval(self: Self, ctx: Context, *, code: str) -> None: # noqa: A003 - not re.search( # Check if it's an expression r"^(return|import|for|while|def|class|from|exit|[a-zA-Z0-9]+\s*=)", code, - re.M, + re.MULTILINE, ) and len(code.split("\n")) == 1 ): diff --git a/src/bot/exts/utilities/snekbox/__init__.py b/src/bot/exts/utilities/snekbox/__init__.py index e4deeae..aca84b3 100644 --- a/src/bot/exts/utilities/snekbox/__init__.py +++ b/src/bot/exts/utilities/snekbox/__init__.py @@ -5,7 +5,7 @@ from ._cog import CodeblockConverter, Snekbox from ._eval import EvalJob, EvalResult -__all__ = ("CodeblockConverter", "Snekbox", "EvalJob", "EvalResult") +__all__ = ("CodeblockConverter", "EvalJob", "EvalResult", "Snekbox") async def setup(bot: Bot) -> None: diff --git a/src/bot/exts/utilities/snekbox/_cog.py b/src/bot/exts/utilities/snekbox/_cog.py index 074e566..b4ef526 100644 --- a/src/bot/exts/utilities/snekbox/_cog.py +++ b/src/bot/exts/utilities/snekbox/_cog.py @@ -1,4 +1,3 @@ -import asyncio import contextlib import re from functools import partial @@ -34,7 +33,7 @@ log = get_logger(__name__) -ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}") +ESCAPE_REGEX = re.compile("[`\u202e\u200b]{3,}") # The timeit command should only output the very last line, so all other output should be suppressed. # This will be used as the setup code along with any setup code provided. @@ -129,7 +128,9 @@ async def convert( code, block, lang, delim = match.group("code", "block", "lang", "delim") codeblocks = [dedent(code)] if block: - info = (f"'{lang}' highlighted" if lang else "plain") + " code block" + info = ( + f"'{lang}' highlighted" if lang else "plain" + ) + " code block" else: info = f"{delim}-enclosed inline code" else: @@ -152,7 +153,9 @@ def __init__( job: EvalJob, ) -> None: self.version_to_switch_to = version_to_switch_to - super().__init__(label=f"Run in {self.version_to_switch_to}", style=enums.ButtonStyle.primary) + super().__init__( + label=f"Run in {self.version_to_switch_to}", style=enums.ButtonStyle.primary + ) self.snekbox_cog = snekbox_cog self.ctx = ctx @@ -173,7 +176,9 @@ async def callback(self: Self, interaction: Interaction) -> None: # The log arg on send_job will stop the actual job from running. await interaction.message.delete() - await self.snekbox_cog.run_job(self.ctx, self.job.as_version(self.version_to_switch_to)) + await self.snekbox_cog.run_job( + self.ctx, self.job.as_version(self.version_to_switch_to) + ) class Snekbox(Cog): @@ -211,7 +216,9 @@ async def post_job(self: Self, job: EvalJob) -> EvalResult: """Send a POST request to the Snekbox API to evaluate code and return the results.""" data = job.to_dict() - async with self.bot.http_session.post(URLs.snekbox_eval_api, json=data, raise_for_status=True) as resp: + async with self.bot.http_session.post( + URLs.snekbox_eval_api, json=data, raise_for_status=True + ) as resp: return EvalResult.from_dict(await resp.json()) @staticmethod @@ -220,7 +227,9 @@ async def upload_output(http_session: ClientSession, output: str) -> str | None: log.trace("Uploading full output to paste service...") try: - return await send_to_paste_service(http_session, output, extension="txt", max_length=MAX_PASTE_LENGTH) + return await send_to_paste_service( + http_session, output, extension="txt", max_length=MAX_PASTE_LENGTH + ) except PasteTooLongError: return "too long to upload" except PasteUploadError: @@ -259,14 +268,19 @@ async def format_output( paste_link = None if "<@" in output: - output = output.replace("<@", "<@\u200B") # Zero-width space + output = output.replace("<@", "<@\u200b") # Zero-width space if " max_lines: truncated = True if len(output) >= max_chars: - output = f"{output[:max_chars]}\n... (truncated - too long, too many lines)" + output = ( + f"{output[:max_chars]}\n... (truncated - too long, too many lines)" + ) else: output = f"{output}\n... (truncated - too many lines)" elif len(output) >= max_chars: @@ -288,14 +304,18 @@ async def format_output( output = f"{output[:max_chars]}\n... (truncated - too long)" if truncated: - paste_link = await self.upload_output(self.bot.http_session, original_output) + paste_link = await self.upload_output( + self.bot.http_session, original_output + ) if output_default and not output: output = output_default return output, paste_link - def _filter_files(self: Self, ctx: Context, files: list[FileAttachment], blocked_exts: set[str]) -> FilteredFiles: + def _filter_files( + self: Self, ctx: Context, files: list[FileAttachment], blocked_exts: set[str] + ) -> FilteredFiles: """Filter to restrict files to allowed extensions. Return a named tuple of allowed and blocked files lists.""" # Filter files into allowed and blocked blocked = [] @@ -337,8 +357,13 @@ async def send_job(self: Self, ctx: Context, job: EvalJob) -> Message: # noqa: # This is done to make sure the last line of output contains the error # and the error is not manually printed by the author with a syntax error. - if result.stdout.rstrip().endswith("EOFError: EOF when reading a line") and result.returncode == 1: - msg += "\n:warning: Note: `input` is not supported by the bot :warning:\n" + if ( + result.stdout.rstrip().endswith("EOFError: EOF when reading a line") + and result.returncode == 1 + ): + msg += ( + "\n:warning: Note: `input` is not supported by the bot :warning:\n" + ) # Skip output if it's empty and there are file uploads if result.stdout or not result.has_files: @@ -385,9 +410,13 @@ async def send_job(self: Self, ctx: Context, job: EvalJob) -> Message: # noqa: failed_files = [FileAttachment(name, b"") for name in result.failed_files] total_files = result.files + failed_files if filter_cog: - block_output, blocked_exts = await filter_cog.filter_snekbox_output(msg, total_files, ctx.message) + block_output, blocked_exts = await filter_cog.filter_snekbox_output( + msg, total_files, ctx.message + ) if block_output: - return await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") + return await ctx.send( + "Attempt to circumvent filter detected. Moderator team has been alerted." + ) # Filter file extensions allowed, blocked = self._filter_files(ctx, result.files, blocked_exts) @@ -401,9 +430,7 @@ async def send_job(self: Self, ctx: Context, job: EvalJob) -> Message: # noqa: # Both elif "" in blocked_sorted: blocked_str = ", ".join(ext for ext in blocked_sorted if ext) - blocked_msg = ( - f"Files with no extension or disallowed extensions can't be uploaded: **{blocked_str}**" - ) + blocked_msg = f"Files with no extension or disallowed extensions can't be uploaded: **{blocked_str}**" else: blocked_str = ", ".join(blocked_sorted) blocked_msg = f"Files with disallowed extensions can't be uploaded: **{blocked_str}**" @@ -412,15 +439,23 @@ async def send_job(self: Self, ctx: Context, job: EvalJob) -> Message: # noqa: # Upload remaining non-text files files = [f.to_file() for f in allowed if f not in text_files] - allowed_mentions = AllowedMentions(everyone=False, roles=False, users=[ctx.author]) + allowed_mentions = AllowedMentions( + everyone=False, roles=False, users=[ctx.author] + ) view = self.build_python_version_switcher_view(job.version, ctx, job) - response = await ctx.send(msg, allowed_mentions=allowed_mentions, view=view, files=files) + response = await ctx.send( + msg, allowed_mentions=allowed_mentions, view=view, files=files + ) view.message = response - log.info(f"{ctx.author}'s {job.name} job had a return code of {result.returncode}") + log.info( + f"{ctx.author}'s {job.name} job had a return code of {result.returncode}" + ) return response - async def continue_job(self: Self, ctx: Context, response: Message, job_name: str) -> EvalJob | None: + async def continue_job( + self: Self, ctx: Context, response: Message, job_name: str + ) -> EvalJob | None: """ Check if the job's session should continue. @@ -438,7 +473,9 @@ async def continue_job(self: Self, ctx: Context, response: Message, job_name: st timeout=REDO_TIMEOUT, ) await ctx.message.add_reaction(REDO_EMOJI) - await self.bot.wait_for("reaction_add", check=_predicate_emoji_reaction, timeout=10) + await self.bot.wait_for( + "reaction_add", check=_predicate_emoji_reaction, timeout=10 + ) # Ensure the response that's about to be edited is still the most recent. # This could have already been updated via a button press to switch to an alt Python version. @@ -453,7 +490,7 @@ async def continue_job(self: Self, ctx: Context, response: Message, job_name: st if code is None: return None - except asyncio.TimeoutError: + except TimeoutError: with contextlib.suppress(HTTPException): await ctx.message.clear_reaction(REDO_EMOJI) return None @@ -545,7 +582,11 @@ async def eval_command( job = EvalJob.from_code("\n".join(code)).as_version(python_version) await self.run_job(ctx, job) - @command(name="timeit", aliases=("ti",), usage="[python_version] [setup_code] ") + @command( + name="timeit", + aliases=("ti",), + usage="[python_version] [setup_code] ", + ) @guild_only() async def timeit_command( self: Self, @@ -584,4 +625,8 @@ def predicate_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> def predicate_emoji_reaction(ctx: Context, reaction: Reaction, user: User) -> bool: """Return True if the reaction REDO_EMOJI was added by the context message author on this message.""" - return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == REDO_EMOJI + return ( + reaction.message.id == ctx.message.id + and user.id == ctx.author.id + and str(reaction) == REDO_EMOJI + ) diff --git a/src/bot/exts/utilities/snekbox/_eval.py b/src/bot/exts/utilities/snekbox/_eval.py index cb3dfc7..af8c83c 100644 --- a/src/bot/exts/utilities/snekbox/_eval.py +++ b/src/bot/exts/utilities/snekbox/_eval.py @@ -159,7 +159,9 @@ def get_message(self: Self, job: EvalJob) -> str: return msg @classmethod - def from_dict(cls: type[Self], data: dict[str, str | int | list[dict[str, str]]]) -> Self: + def from_dict( + cls: type[Self], data: dict[str, str | int | list[dict[str, str]]] + ) -> Self: """Create an EvalResult from a dict.""" res = cls( stdout=data["stdout"], diff --git a/src/bot/exts/utilities/snekbox/_io.py b/src/bot/exts/utilities/snekbox/_io.py index b7831f0..90836ca 100644 --- a/src/bot/exts/utilities/snekbox/_io.py +++ b/src/bot/exts/utilities/snekbox/_io.py @@ -71,7 +71,9 @@ def name(self: Self) -> str: return PurePosixPath(self.filename).name @classmethod - def from_dict(cls: type[Self], data: dict, size_limit: int = FILE_SIZE_LIMIT) -> Self: + def from_dict( + cls: type[Self], data: dict, size_limit: int = FILE_SIZE_LIMIT + ) -> Self: """Create a FileAttachment from a dict response.""" size = data.get("size") if (size and size > size_limit) or (len(data["content"]) > size_limit): diff --git a/src/bot/log.py b/src/bot/log.py index 9125f18..1d2c42d 100644 --- a/src/bot/log.py +++ b/src/bot/log.py @@ -54,7 +54,9 @@ def setup() -> None: if constants.FILE_LOGS: log_file = Path("logs", "bot.log") log_file.parent.mkdir(exist_ok=True) - file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8") + file_handler = handlers.RotatingFileHandler( + log_file, maxBytes=5242880, backupCount=7, encoding="utf8" + ) file_handler.setFormatter(log_format) root_log.addHandler(file_handler) @@ -85,7 +87,9 @@ def setup() -> None: def setup_sentry() -> None: """Set up the Sentry logging integrations.""" - sentry_logging = LoggingIntegration(level=logging.DEBUG, event_level=logging.WARNING) + sentry_logging = LoggingIntegration( + level=logging.DEBUG, event_level=logging.WARNING + ) sentry_sdk.init( dsn=constants.Bot.sentry_dsn, diff --git a/src/bot/utils/__init__.py b/src/bot/utils/__init__.py index ab777eb..721eee6 100644 --- a/src/bot/utils/__init__.py +++ b/src/bot/utils/__init__.py @@ -9,10 +9,10 @@ __all__ = [ "CogABCMeta", + "PasteTooLongError", + "PasteUploadError", "find_nth_occurrence", "has_lines", "pad_base64", "send_to_paste_service", - "PasteUploadError", - "PasteTooLongError", ] diff --git a/src/bot/utils/commands.py b/src/bot/utils/commands.py index 6bf1d44..6f3dd1c 100644 --- a/src/bot/utils/commands.py +++ b/src/bot/utils/commands.py @@ -3,7 +3,9 @@ from rapidfuzz import process -def get_command_suggestions(all_commands: list[str], query: str, *, cutoff: int = 60, limit: int = 3) -> list[str]: +def get_command_suggestions( + all_commands: list[str], query: str, *, cutoff: int = 60, limit: int = 3 +) -> list[str]: """Get similar command names.""" results = process.extract(query, all_commands, score_cutoff=cutoff, limit=limit) return [result[0] for result in results] diff --git a/src/bot/utils/exceptions.py b/src/bot/utils/exceptions.py index edc0fcf..efd2d8a 100644 --- a/src/bot/utils/exceptions.py +++ b/src/bot/utils/exceptions.py @@ -7,7 +7,9 @@ class APIError(Exception): """Raised when an external API (eg. Wikipedia) returns an error response.""" - def __init__(self: Self, api: str, status_code: int, error_msg: str | None = None) -> None: + def __init__( + self: Self, api: str, status_code: int, error_msg: str | None = None + ) -> None: super().__init__() self.api = api self.status_code = status_code diff --git a/src/bot/utils/extensions.py b/src/bot/utils/extensions.py index d3921f8..ce5ce2f 100644 --- a/src/bot/utils/extensions.py +++ b/src/bot/utils/extensions.py @@ -44,7 +44,9 @@ def on_error(name: str) -> NoReturn: modules = set() - for module_info in pkgutil.walk_packages(module.__path__, f"{module.__name__}.", onerror=on_error): + for module_info in pkgutil.walk_packages( + module.__path__, f"{module.__name__}.", onerror=on_error + ): if ignore_module(module_info): # Ignore modules/packages that have a name starting with an underscore anywhere in their trees. continue diff --git a/src/bot/utils/function.py b/src/bot/utils/function.py index 3ccd3ef..1ceb9ec 100644 --- a/src/bot/utils/function.py +++ b/src/bot/utils/function.py @@ -36,7 +36,7 @@ def get_arg_value(name_or_pos: Argument, arguments: BoundArgs) -> Any: # noqa: arg_pos = name_or_pos try: - name, value = arg_values[arg_pos] + _name, value = arg_values[arg_pos] return value # noqa: TRY300 - try/except/else is ugly except IndexError as exception: msg = f"Argument position {arg_pos} is out of bounds." @@ -112,11 +112,15 @@ def update_wrapper_globals( as this can cause incorrect objects being used by discordpy's converters. """ annotation_global_names = ( - ann.split(".", maxsplit=1)[0] for ann in wrapped.__annotations__.values() if isinstance(ann, str) + ann.split(".", maxsplit=1)[0] + for ann in wrapped.__annotations__.values() + if isinstance(ann, str) ) # Conflicting globals from both functions' modules that are also used in the wrapper and in wrapped's annotations. shared_globals = set(wrapper.__code__.co_names) & set(annotation_global_names) - shared_globals &= set(wrapped.__globals__) & set(wrapper.__globals__) - ignored_conflict_names + shared_globals &= ( + set(wrapped.__globals__) & set(wrapper.__globals__) - ignored_conflict_names + ) if shared_globals: msg = ( f"wrapper and the wrapped function share the following global names used by annotations: {', '.join(shared_globals)}." # noqa: E501 - strings @@ -125,7 +129,11 @@ def update_wrapper_globals( raise GlobalNameConflictError(msg) new_globals = wrapper.__globals__.copy() - new_globals.update((k, v) for k, v in wrapped.__globals__.items() if k not in wrapper.__code__.co_names) + new_globals.update( + (k, v) + for k, v in wrapped.__globals__.items() + if k not in wrapper.__code__.co_names + ) return types.FunctionType( code=wrapper.__code__, globals=new_globals, @@ -146,7 +154,9 @@ def command_wraps( def decorator(wrapper: types.FunctionType) -> types.FunctionType: return functools.update_wrapper( - update_wrapper_globals(wrapper, wrapped, ignored_conflict_names=ignored_conflict_names), + update_wrapper_globals( + wrapper, wrapped, ignored_conflict_names=ignored_conflict_names + ), wrapped, assigned, updated, diff --git a/src/bot/utils/lock.py b/src/bot/utils/lock.py index bb4f5e1..843f39e 100644 --- a/src/bot/utils/lock.py +++ b/src/bot/utils/lock.py @@ -97,7 +97,9 @@ async def wrapper(*args: list, **kwargs: dict) -> Callable | None: else: id_ = resource_id - log.trace(f"{name}: getting the lock object for resource {namespace!r}:{id_!r}") + log.trace( + f"{name}: getting the lock object for resource {namespace!r}:{id_!r}" + ) # Get the lock for the ID. Create a lock if one doesn't exist yet. locks = __lock_dicts[namespace] @@ -108,11 +110,15 @@ async def wrapper(*args: list, **kwargs: dict) -> Callable | None: # 2. `asyncio.Lock.acquire()` does not internally await anything if the lock is free # 3. awaits only yield execution to the event loop at actual I/O boundaries if wait or not lock_.locked(): - log.debug(f"{name}: acquiring lock for resource {namespace!r}:{id_!r}...") + log.debug( + f"{name}: acquiring lock for resource {namespace!r}:{id_!r}..." + ) async with lock_: return await func(*args, **kwargs) else: - log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") + log.info( + f"{name}: aborted because resource {namespace!r}:{id_!r} is locked" + ) if raise_error: raise LockedResourceError(str(namespace), id_) return None diff --git a/src/bot/utils/services.py b/src/bot/utils/services.py index 7ce50eb..e805946 100644 --- a/src/bot/utils/services.py +++ b/src/bot/utils/services.py @@ -75,7 +75,9 @@ async def send_to_paste_service( ) continue if "key" in response_json: - log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") + log.info( + f"Successfully uploaded contents to paste service behind key {response_json['key']}." + ) paste_link = URLs.paste_service.format(key=response_json["key"]) + extension