From 038a365a16782951f6ebbaee32b47b94c98c4925 Mon Sep 17 00:00:00 2001 From: shiftinv <8530778+shiftinv@users.noreply.github.com> Date: Sun, 29 Oct 2023 13:00:20 +0100 Subject: [PATCH 1/6] fix(ui): don't require `cls` argument in select decorators to be positional (#1111) --- changelog/1111.bugfix.rst | 1 + disnake/ui/button.py | 2 +- disnake/ui/select/channel.py | 6 ++---- disnake/ui/select/mentionable.py | 6 ++---- disnake/ui/select/role.py | 6 ++---- disnake/ui/select/string.py | 6 ++---- disnake/ui/select/user.py | 6 ++---- docs/api/ui.rst | 2 +- tests/ui/test_decorators.py | 21 ++++++++++++++++----- 9 files changed, 29 insertions(+), 27 deletions(-) create mode 100644 changelog/1111.bugfix.rst diff --git a/changelog/1111.bugfix.rst b/changelog/1111.bugfix.rst new file mode 100644 index 0000000000..4efa8693ed --- /dev/null +++ b/changelog/1111.bugfix.rst @@ -0,0 +1 @@ +Allow ``cls`` argument in select menu decorators (e.g. :func`ui.string_select`) to be specified by keyword instead of being positional-only. diff --git a/disnake/ui/button.py b/disnake/ui/button.py index 8bb1e60ff2..d5e1fc7708 100644 --- a/disnake/ui/button.py +++ b/disnake/ui/button.py @@ -295,7 +295,7 @@ def button( ---------- cls: Type[:class:`Button`] The button subclass to create an instance of. If provided, the following parameters - described below do no apply. Instead, this decorator will accept the same keywords + described below do not apply. Instead, this decorator will accept the same keywords as the passed cls does. .. versionadded:: 2.6 diff --git a/disnake/ui/select/channel.py b/disnake/ui/select/channel.py index a455172799..a98472b547 100644 --- a/disnake/ui/select/channel.py +++ b/disnake/ui/select/channel.py @@ -168,9 +168,7 @@ def channel_select( def channel_select( - cls: Type[Object[S_co, P]] = ChannelSelect[Any], - /, - **kwargs: Any, + cls: Type[Object[S_co, P]] = ChannelSelect[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: """A decorator that attaches a channel select menu to a component. @@ -187,7 +185,7 @@ def channel_select( ---------- cls: Type[:class:`ChannelSelect`] The select subclass to create an instance of. If provided, the following parameters - described below do no apply. Instead, this decorator will accept the same keywords + described below do not apply. Instead, this decorator will accept the same keywords as the passed cls does. placeholder: Optional[:class:`str`] The placeholder text that is shown if nothing is selected, if any. diff --git a/disnake/ui/select/mentionable.py b/disnake/ui/select/mentionable.py index c9e5802f78..4f0d591201 100644 --- a/disnake/ui/select/mentionable.py +++ b/disnake/ui/select/mentionable.py @@ -144,9 +144,7 @@ def mentionable_select( def mentionable_select( - cls: Type[Object[S_co, P]] = MentionableSelect[Any], - /, - **kwargs: Any, + cls: Type[Object[S_co, P]] = MentionableSelect[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: """A decorator that attaches a mentionable (user/member/role) select menu to a component. @@ -163,7 +161,7 @@ def mentionable_select( ---------- cls: Type[:class:`MentionableSelect`] The select subclass to create an instance of. If provided, the following parameters - described below do no apply. Instead, this decorator will accept the same keywords + described below do not apply. Instead, this decorator will accept the same keywords as the passed cls does. placeholder: Optional[:class:`str`] The placeholder text that is shown if nothing is selected, if any. diff --git a/disnake/ui/select/role.py b/disnake/ui/select/role.py index 4644b9a660..69b1bcaa57 100644 --- a/disnake/ui/select/role.py +++ b/disnake/ui/select/role.py @@ -142,9 +142,7 @@ def role_select( def role_select( - cls: Type[Object[S_co, P]] = RoleSelect[Any], - /, - **kwargs: Any, + cls: Type[Object[S_co, P]] = RoleSelect[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: """A decorator that attaches a role select menu to a component. @@ -161,7 +159,7 @@ def role_select( ---------- cls: Type[:class:`RoleSelect`] The select subclass to create an instance of. If provided, the following parameters - described below do no apply. Instead, this decorator will accept the same keywords + described below do not apply. Instead, this decorator will accept the same keywords as the passed cls does. placeholder: Optional[:class:`str`] The placeholder text that is shown if nothing is selected, if any. diff --git a/disnake/ui/select/string.py b/disnake/ui/select/string.py index 0a975c2aa8..d38c9ea6ba 100644 --- a/disnake/ui/select/string.py +++ b/disnake/ui/select/string.py @@ -268,9 +268,7 @@ def string_select( def string_select( - cls: Type[Object[S_co, P]] = StringSelect[Any], - /, - **kwargs: Any, + cls: Type[Object[S_co, P]] = StringSelect[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: """A decorator that attaches a string select menu to a component. @@ -288,7 +286,7 @@ def string_select( ---------- cls: Type[:class:`StringSelect`] The select subclass to create an instance of. If provided, the following parameters - described below do no apply. Instead, this decorator will accept the same keywords + described below do not apply. Instead, this decorator will accept the same keywords as the passed cls does. .. versionadded:: 2.6 diff --git a/disnake/ui/select/user.py b/disnake/ui/select/user.py index 9a995739fc..179b9d6c74 100644 --- a/disnake/ui/select/user.py +++ b/disnake/ui/select/user.py @@ -143,9 +143,7 @@ def user_select( def user_select( - cls: Type[Object[S_co, P]] = UserSelect[Any], - /, - **kwargs: Any, + cls: Type[Object[S_co, P]] = UserSelect[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: """A decorator that attaches a user select menu to a component. @@ -162,7 +160,7 @@ def user_select( ---------- cls: Type[:class:`UserSelect`] The select subclass to create an instance of. If provided, the following parameters - described below do no apply. Instead, this decorator will accept the same keywords + described below do not apply. Instead, this decorator will accept the same keywords as the passed cls does. placeholder: Optional[:class:`str`] The placeholder text that is shown if nothing is selected, if any. diff --git a/docs/api/ui.rst b/docs/api/ui.rst index 85c85e37c4..c7c061f137 100644 --- a/docs/api/ui.rst +++ b/docs/api/ui.rst @@ -128,7 +128,7 @@ TextInput Functions --------- -.. autofunction:: button(cls=Button, *, style=ButtonStyle.secondary, label=None, disabled=False, custom_id=..., url=None, emoji=None, row=None) +.. autofunction:: button(cls=Button, *, custom_id=..., style=ButtonStyle.secondary, label=None, disabled=False, url=None, emoji=None, row=None) :decorator: .. autofunction:: string_select(cls=StringSelect, *, custom_id=..., placeholder=None, min_values=1, max_values=1, options=..., disabled=False, row=None) diff --git a/tests/ui/test_decorators.py b/tests/ui/test_decorators.py index 86ecd65ba9..5fab1bb787 100644 --- a/tests/ui/test_decorators.py +++ b/tests/ui/test_decorators.py @@ -44,7 +44,7 @@ def test_default(self) -> None: assert func.__discord_ui_model_type__ is ui.StringSelect assert func.__discord_ui_model_kwargs__ == {"custom_id": "123"} - # from here on out we're only testing the button decorator, + # from here on out we're mostly only testing the button decorator, # as @ui.string_select etc. works identically @pytest.mark.parametrize("cls", [_CustomButton, _CustomButton[Any]]) @@ -64,7 +64,18 @@ def _test_typing_cls(self) -> None: this_should_not_work="h", # type: ignore ) - @pytest.mark.parametrize("cls", [123, int, ui.StringSelect]) - def test_cls_invalid(self, cls) -> None: - with pytest.raises(TypeError, match=r"cls argument must be"): - ui.button(cls=cls) # type: ignore + @pytest.mark.parametrize( + ("decorator", "invalid_cls"), + [ + (ui.button, ui.StringSelect), + (ui.string_select, ui.Button), + (ui.user_select, ui.Button), + (ui.role_select, ui.Button), + (ui.mentionable_select, ui.Button), + (ui.channel_select, ui.Button), + ], + ) + def test_cls_invalid(self, decorator, invalid_cls) -> None: + for cls in [123, int, invalid_cls]: + with pytest.raises(TypeError, match=r"cls argument must be"): + decorator(cls=cls) From 627e9e90b99ca673428a2382dfb4f5549d7cf64e Mon Sep 17 00:00:00 2001 From: shiftinv <8530778+shiftinv@users.noreply.github.com> Date: Mon, 30 Oct 2023 20:27:45 +0100 Subject: [PATCH 2/6] feat(ci): automated release workflow (#1072) --- .github/CODEOWNERS | 3 + .github/workflows/build-release.yaml | 227 +++++++++++++++++++++++ .github/workflows/changelog.yaml | 2 +- .github/workflows/create-release-pr.yaml | 81 ++++++++ .github/workflows/lint-test.yml | 9 +- .pre-commit-config.yaml | 2 +- RELEASE.md | 47 +++++ disnake/__init__.py | 2 + pyproject.toml | 12 +- scripts/ci/versiontool.py | 128 +++++++++++++ 10 files changed, 505 insertions(+), 8 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/build-release.yaml create mode 100644 .github/workflows/create-release-pr.yaml create mode 100644 RELEASE.md create mode 100644 scripts/ci/versiontool.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..ccbf0ff248 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +/.github @DisnakeDev/maintainers +/scripts/ci @DisnakeDev/maintainers diff --git a/.github/workflows/build-release.yaml b/.github/workflows/build-release.yaml new file mode 100644 index 0000000000..692affbd8b --- /dev/null +++ b/.github/workflows/build-release.yaml @@ -0,0 +1,227 @@ +# SPDX-License-Identifier: MIT + +name: Build (+ Release) + +# test build for commit/tag, but only upload release for tags +on: + push: + branches: + - "master" + - 'v[0-9]+.[0-9]+.x' # matches to backport branches, e.g. v3.6.x + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +permissions: + contents: read + +jobs: + # Builds sdist and wheel, runs `twine check`, and optionally uploads artifacts. + build: + name: Build package + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up environment + id: setup + uses: ./.github/actions/setup-env + with: + python-version: 3.8 + + - name: Install dependencies + run: pdm install -dG build + + - name: Build package + run: | + pdm run python -m build + ls -la dist/ + + - name: Twine check + run: pdm run twine check --strict dist/* + + - name: Show metadata + run: | + mkdir out/ + tar -xf dist/*.tar.gz -C out/ + + echo -e "
Metadata\n" >> $GITHUB_STEP_SUMMARY + cat out/*/PKG-INFO | sed 's/^/ /' | tee -a $GITHUB_STEP_SUMMARY + echo -e "\n
\n" >> $GITHUB_STEP_SUMMARY + + - name: Upload artifact + # only upload artifacts when necessary + if: startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@v3 + with: + name: dist + path: dist/ + if-no-files-found: error + + + ### Anything below this only runs for tags ### + + # Ensures that git tag and built version match. + validate-tag: + name: Validate tag + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + needs: + - build + env: + GIT_TAG: ${{ github.ref_name }} + outputs: + bump_dev: ${{ steps.check-dev.outputs.bump_dev }} + + steps: + - name: Download build artifact + uses: actions/download-artifact@v3 + with: + name: dist + path: dist/ + + - name: Compare sdist version to git tag + run: | + mkdir out/ + tar -xf dist/*.tar.gz -C out/ + + SDIST_VERSION="$(grep "^Version:" out/*/PKG-INFO | cut -d' ' -f2-)" + echo "git tag: $GIT_TAG" + echo "sdist version: $SDIST_VERSION" + + if [ "$GIT_TAG" != "v$SDIST_VERSION" ]; then + echo "error: git tag does not match sdist version" >&2 + exit 1 + fi + + - name: Determine if dev version PR is needed + id: check-dev + run: | + BUMP_DEV= + # if this is a new major/minor version, create a PR later + if [[ "$GIT_TAG" =~ ^v[0-9]+\.[0-9]+\.0$ ]]; then + BUMP_DEV=1 + fi + echo "bump_dev=$BUMP_DEV" | tee -a $GITHUB_OUTPUT + + + # Creates a draft release on GitHub, and uploads the artifacts there. + release-github: + name: Create GitHub draft release + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + needs: + - build + - validate-tag + permissions: + contents: write # required for creating releases + + steps: + - name: Download build artifact + uses: actions/download-artifact@v3 + with: + name: dist + path: dist/ + + - name: Calculate versions + id: versions + env: + GIT_TAG: ${{ github.ref_name }} + run: | + # v1.2.3 -> v1-2-3 (for changelog) + echo "docs_version=${GIT_TAG//./-}" >> $GITHUB_OUTPUT + + - name: Create Release + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v0.1.15 + with: + files: dist/* + draft: true + body: | + TBD. + + **Changelog**: https://docs.disnake.dev/en/stable/whats_new.html#${{ steps.versions.outputs.docs_version }} + **Git history**: https://github.com/${{ github.repository }}/compare/vTODO...${{ github.ref_name }} + + + # Creates a PyPI release (using an environment which requires separate confirmation). + release-pypi: + name: Publish package to pypi.org + environment: + name: release-pypi + url: https://pypi.org/project/disnake/ + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + needs: + - build + - validate-tag + permissions: + id-token: write # this permission is mandatory for trusted publishing + + steps: + - name: Download build artifact + uses: actions/download-artifact@v3 + with: + name: dist + path: dist/ + + - name: Upload to pypi + uses: pypa/gh-action-pypi-publish@f5622bde02b04381239da3573277701ceca8f6a0 # v1.8.7 + with: + print-hash: true + + + # Creates a PR to bump to an alpha version for development, if applicable. + create-dev-version-pr: + name: Create dev version bump PR + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') && needs.validate-tag.outputs.bump_dev + needs: + - validate-tag + - release-github + - release-pypi + + steps: + # https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/making-authenticated-api-requests-with-a-github-app-in-a-github-actions-workflow + - name: Generate app token + id: generate_token + uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0 + with: + app_id: ${{ secrets.BOT_APP_ID }} + private_key: ${{ secrets.BOT_PRIVATE_KEY }} + + - uses: actions/checkout@v3 + with: + token: ${{ steps.generate_token.outputs.token }} + persist-credentials: false + ref: master # the PR action wants a proper base branch + + - name: Set git name/email + env: + GIT_USER: ${{ vars.GIT_APP_USER_NAME }} + GIT_EMAIL: ${{ vars.GIT_APP_USER_EMAIL }} + run: | + git config user.name "$GIT_USER" + git config user.email "$GIT_EMAIL" + + - name: Update version to dev + id: update-version + run: | + NEW_VERSION="$(python scripts/ci/versiontool.py --set dev)" + git commit -a -m "chore: update version to v$NEW_VERSION" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Create pull request + uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5.0.2 + with: + token: ${{ steps.generate_token.outputs.token }} + branch: auto/dev-v${{ steps.update-version.outputs.new_version }} + delete-branch: true + base: master + title: "chore: update version to v${{ steps.update-version.outputs.new_version }}" + body: | + Automated dev version PR. + + https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + labels: | + skip news + t: meta diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml index 8fdb8e27c7..f2a989c0c4 100644 --- a/.github/workflows/changelog.yaml +++ b/.github/workflows/changelog.yaml @@ -33,7 +33,7 @@ jobs: python-version: '3.9' - name: Install dependencies - run: pdm install -dG tools + run: pdm install -dG changelog - name: Check for presence of a Change Log fragment (only pull requests) # NOTE: The pull request' base branch needs to be fetched so towncrier diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml new file mode 100644 index 0000000000..56c32a4ac5 --- /dev/null +++ b/.github/workflows/create-release-pr.yaml @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: MIT + +name: Create Release PR + +on: + workflow_dispatch: + inputs: + version: + description: "The new version number, e.g. `1.2.3`." + type: string + required: true + +permissions: {} + +jobs: + create-release-pr: + name: Create Release PR + runs-on: ubuntu-latest + + env: + VERSION_INPUT: ${{ inputs.version }} + + steps: + # https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/making-authenticated-api-requests-with-a-github-app-in-a-github-actions-workflow + - name: Generate app token + id: generate_token + uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0 + with: + app_id: ${{ secrets.BOT_APP_ID }} + private_key: ${{ secrets.BOT_PRIVATE_KEY }} + + - uses: actions/checkout@v3 + with: + token: ${{ steps.generate_token.outputs.token }} + persist-credentials: false + + - name: Set git name/email + env: + GIT_USER: ${{ vars.GIT_APP_USER_NAME }} + GIT_EMAIL: ${{ vars.GIT_APP_USER_EMAIL }} + run: | + git config user.name "$GIT_USER" + git config user.email "$GIT_EMAIL" + + - name: Set up environment + uses: ./.github/actions/setup-env + with: + python-version: 3.8 + + - name: Install dependencies + run: pdm install -dG changelog + + - name: Update version + run: | + python scripts/ci/versiontool.py --set "$VERSION_INPUT" + git commit -a -m "chore: update version to $VERSION_INPUT" + + - name: Build changelog + run: | + pdm run towncrier build --yes --version "$VERSION_INPUT" + git commit -a -m "docs: build changelog" + + - name: Create pull request + uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5.0.2 + with: + token: ${{ steps.generate_token.outputs.token }} + branch: auto/release-v${{ inputs.version }} + delete-branch: true + title: "release: v${{ inputs.version }}" + body: | + Automated release PR, triggered by @${{ github.actor }} for ${{ github.sha }}. + + ### Tasks + - [ ] Add changelogs from backports, if applicable. + - [ ] Once merged, create + push a tag. + + https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + labels: | + t: release + assignees: | + ${{ github.actor }} diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 696bdc5fb0..64dc2e18a7 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -6,7 +6,7 @@ on: push: branches: - 'master' - - 'v[0-9]+.[0-9]+.x' # matches to backport branches, e.g. 3.6 + - 'v[0-9]+.[0-9]+.x' # matches to backport branches, e.g. v3.6.x - 'run-ci/*' tags: pull_request: @@ -124,10 +124,11 @@ jobs: run: nox -s check-manifest # This only runs if the previous steps were successful, no point in running it otherwise - - name: Build package + - name: Try building package run: | - python -m pip install -U build - python -m build + pdm install -dG build + pdm run python -m build + ls -la dist/ # run the libcst parsers and check for changes - name: libcst codemod diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c599e6c6d..104e2264a4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,7 +35,7 @@ repos: args: [--negate] types: [text] exclude_types: [json, pofile] - exclude: 'changelog/|py.typed|disnake/bin/COPYING|.github/PULL_REQUEST_TEMPLATE.md|LICENSE|MANIFEST.in' + exclude: 'changelog/|py.typed|disnake/bin/COPYING|.github/PULL_REQUEST_TEMPLATE.md|.github/CODEOWNERS|LICENSE|MANIFEST.in' - repo: https://github.com/pycqa/isort rev: 5.12.0 diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000000..3919b696e5 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,47 @@ + + +# Release Procedure + +This document provides general information and steps about the project's release procedure. +If you're reading this, this will likely not be useful to you, unless you have administrator permissions in the repository or want to replicate this setup in your own project :p + +The process is largely automated, with manual action only being needed where higher permissions are required. +Note that pre-releases (alpha/beta/rc) don't quite work with the current setup; we don't currently anticipate making pre-releases, but this may still be improved in the future. + + +## Steps + +These steps are mostly equivalent for major/minor (feature) and micro (bugfix) releases. +The branch should be `master` for major/minor releases and e.g. `1.2.x` for micro releases. + +1. Run the `Create Release PR` workflow from the GitHub UI (or CLI), specifying the correct branch and new version. + 1. Wait until a PR containing the changelog and version bump is created. Update the changelog description and merge the PR. + 2. In the CLI, fetch changes and create + push a tag for the newly created commit, which will trigger another workflow. + - [if latest] Also force-push a `stable` tag for the same ref. + 3. Update the visibility of old/new versions on https://readthedocs.org. +2. Approve the environment deployment when prompted, which will push the package to PyPI. + 1. Update and publish the created GitHub draft release, as well as a Discord announcement. 🎉 +3. [if major/minor] Create a `v1.2.x` branch for future backports, and merge the newly created dev version PR. + + +### Manual Steps + +If the automated process above does not work for some reason, here's the abridged version of the manual release process: + +1. Update version in `__init__.py`, run `towncrier build`. Commit, push, create + merge PR. +2. Follow steps 1.ii. + 1.iii. like above. +3. Run `python -m build`, attach artifacts to GitHub release. +4. Run `twine check dist/*` + `twine upload dist/*`. +5. Follow steps 2.i. + 3. like above. + + +## Repository Setup + +This automated process requires some initial one-time setup in the repository to work properly: + +1. Create a GitHub App ([docs](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/making-authenticated-api-requests-with-a-github-app-in-a-github-actions-workflow)), enable write permissions for `content` and `pull_requests`. +2. Install the app in the repository. +3. Set repository variables `GIT_APP_USER_NAME` and `GIT_APP_USER_EMAIL` accordingly. +4. Set repository secrets `BOT_APP_ID` and `BOT_PRIVATE_KEY`. +5. Create a `release-pypi` environment, add protection rules. +6. Set up trusted publishing on PyPI ([docs](https://docs.pypi.org/trusted-publishers/adding-a-publisher/)). diff --git a/disnake/__init__.py b/disnake/__init__.py index 396bab5e43..0cfd1b53d0 100644 --- a/disnake/__init__.py +++ b/disnake/__init__.py @@ -81,6 +81,8 @@ class VersionInfo(NamedTuple): serial: int +# fmt: off version_info: VersionInfo = VersionInfo(major=2, minor=10, micro=0, releaselevel="alpha", serial=0) +# fmt: on logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/pyproject.toml b/pyproject.toml index 984bdf767f..4756d55c4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,10 +70,12 @@ tools = [ "pre-commit~=3.0", "slotscheck~=0.16.4", "python-dotenv~=1.0.0", - "towncrier==23.6.0", "check-manifest==0.49", "ruff==0.0.292", ] +changelog = [ + "towncrier==23.6.0", +] codemod = [ # run codemods on the respository (mostly automated typing) "libcst~=1.1.0", @@ -94,6 +96,11 @@ test = [ "looptime~=0.2", "coverage[toml]~=6.5.0", ] +build = [ + "wheel~=0.40.0", + "build~=0.10.0", + "twine~=4.0.2", +] [tool.pdm.scripts] black = { composite = ["lint black"], help = "Run black" } @@ -230,8 +237,8 @@ ignore = [ "T201", # print found, printing is currently accepted in the test bot "PT", # this is not a module of pytest tests ] -"scripts/*.py" = ["S101"] # use of assert is okay in scripts "tests/*.py" = ["S101"] # use of assert is okay in test files +"scripts/*.py" = ["S101"] # use of assert is okay in scripts # we are not using noqa in the example files themselves "examples/*.py" = [ "B008", # do not perform function calls in argument defaults, this is how most commands work @@ -378,6 +385,7 @@ ignore = [ "noxfile.py", # docs "CONTRIBUTING.md", + "RELEASE.md", "assets/**", "changelog/**", "docs/**", diff --git a/scripts/ci/versiontool.py b/scripts/ci/versiontool.py new file mode 100644 index 0000000000..cec50cff3d --- /dev/null +++ b/scripts/ci/versiontool.py @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import argparse +import re +import sys +from enum import Enum +from pathlib import Path +from typing import NamedTuple, NoReturn + +TARGET_FILE = Path("disnake/__init__.py") +ORIG_INIT_CONTENTS = TARGET_FILE.read_text("utf-8") + +version_re = re.compile(r"(\d+)\.(\d+)\.(\d+)(?:(a|b|rc)(\d+)?)?") + + +class ReleaseLevel(Enum): + alpha = "a" + beta = "b" + candidate = "rc" + final = "" + + +class VersionInfo(NamedTuple): + major: int + minor: int + micro: int + releaselevel: ReleaseLevel + serial: int + + @classmethod + def from_str(cls, s: str) -> VersionInfo: + match = version_re.fullmatch(s) + if not match: + raise ValueError(f"invalid version: '{s}'") + + major, minor, micro, releaselevel, serial = match.groups() + return VersionInfo( + int(major), + int(minor), + int(micro), + ReleaseLevel(releaselevel or ""), + int(serial or 0), + ) + + def __str__(self) -> str: + s = f"{self.major}.{self.minor}.{self.micro}" + if self.releaselevel is not ReleaseLevel.final: + s += self.releaselevel.value + if self.serial: + s += str(self.serial) + return s + + def to_versioninfo(self) -> str: + return ( + f"VersionInfo(major={self.major}, minor={self.minor}, micro={self.micro}, " + f'releaselevel="{self.releaselevel.name}", serial={self.serial})' + ) + + +def get_current_version() -> VersionInfo: + match = re.search(r"^__version__\b.*\"(.+?)\"$", ORIG_INIT_CONTENTS, re.MULTILINE) + assert match, "could not find current version in __init__.py" + return VersionInfo.from_str(match[1]) + + +def replace_line(text: str, regex: str, repl: str) -> str: + lines = [] + found = False + + for line in text.split("\n"): + if re.search(regex, line): + found = True + line = repl + lines.append(line) + + assert found, f"failed to find `{regex}` in file" + return "\n".join(lines) + + +def fail(msg: str) -> NoReturn: + print("error:", msg, file=sys.stderr) + sys.exit(1) + + +def main() -> None: + parser = argparse.ArgumentParser() + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--set", metavar="VERSION", help="set new version (e.g. '1.2.3' or 'dev')") + group.add_argument("--show", action="store_true", help="print current version") + args = parser.parse_args() + + current_version = get_current_version() + + if args.show: + print(str(current_version)) + return + + # else, update to specified version + new_version_str = args.set + + if new_version_str == "dev": + if current_version.releaselevel is not ReleaseLevel.final: + fail("Current version must be final to bump to dev version") + new_version = VersionInfo( + major=current_version.major, + minor=current_version.minor + 1, + micro=0, + releaselevel=ReleaseLevel.alpha, + serial=0, + ) + else: + new_version = VersionInfo.from_str(new_version_str) + + text = ORIG_INIT_CONTENTS + text = replace_line(text, r"^__version__\b", f'__version__ = "{new_version!s}"') + text = replace_line( + text, r"^version_info\b", f"version_info: VersionInfo = {new_version.to_versioninfo()}" + ) + + if text != ORIG_INIT_CONTENTS: + TARGET_FILE.write_text(text, "utf-8") + + print(str(new_version)) + + +main() From 25d1d7a3f5dc09f811a8737613ef3bef7fba38d9 Mon Sep 17 00:00:00 2001 From: shiftinv <8530778+shiftinv@users.noreply.github.com> Date: Fri, 3 Nov 2023 21:08:42 +0100 Subject: [PATCH 3/6] docs: add v2.9.1 changelog to latest docs (#1131) --- docs/whats_new.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/whats_new.rst b/docs/whats_new.rst index d1d3064891..d9bd644863 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -17,6 +17,22 @@ in specific versions. Please see :ref:`version_guarantees` for more information. .. towncrier release notes start +.. _vp2p9p1: + +v2.9.1 +------ + +Bug Fixes +~~~~~~~~~ +- Allow ``cls`` argument in select menu decorators (e.g. :func:`ui.string_select`) to be specified by keyword instead of being positional-only. (:issue:`1111`) +- |commands| Fix edge case in evaluation of multiple identical annotations with forwardrefs in a single signature. (:issue:`1120`) +- Fix :meth:`Thread.permissions_for` not working in some cases due to an incorrect import. (:issue:`1123`) + +Documentation +~~~~~~~~~~~~~ +- Miscellaneous grammar/typo fixes for :doc:`api/audit_logs`. (:issue:`1105`) + + .. _vp2p9p0: v2.9.0 From 59b101f923c193a2f58b7a844eae47a3fb7869c0 Mon Sep 17 00:00:00 2001 From: lena <77104725+elenakrittik@users.noreply.github.com> Date: Sat, 4 Nov 2023 21:59:16 +0300 Subject: [PATCH 4/6] fix(commands): fix application command checks' typing (#1048) Signed-off-by: lena <77104725+elenakrittik@users.noreply.github.com> Co-authored-by: shiftinv <8530778+shiftinv@users.noreply.github.com> --- changelog/1045.bugfix.rst | 1 + changelog/1045.feature.rst | 1 + disnake/ext/commands/_types.py | 6 +++ disnake/ext/commands/base_core.py | 10 ++-- disnake/ext/commands/cog.py | 24 +++++----- disnake/ext/commands/core.py | 50 +++++++++++++++++++- disnake/ext/commands/errors.py | 15 +++--- disnake/ext/commands/interaction_bot_base.py | 14 +++--- docs/ext/commands/api/checks.rst | 6 +++ 9 files changed, 96 insertions(+), 31 deletions(-) create mode 100644 changelog/1045.bugfix.rst create mode 100644 changelog/1045.feature.rst diff --git a/changelog/1045.bugfix.rst b/changelog/1045.bugfix.rst new file mode 100644 index 0000000000..baa41c937a --- /dev/null +++ b/changelog/1045.bugfix.rst @@ -0,0 +1 @@ +|commands| Fix incorrect typings of :meth:`~disnake.ext.commands.InvokableApplicationCommand.add_check`, :meth:`~disnake.ext.commands.InvokableApplicationCommand.remove_check`, :meth:`~disnake.ext.commands.InteractionBotBase.add_app_command_check` and :meth:`~disnake.ext.commands.InteractionBotBase.remove_app_command_check`. diff --git a/changelog/1045.feature.rst b/changelog/1045.feature.rst new file mode 100644 index 0000000000..85af28ec63 --- /dev/null +++ b/changelog/1045.feature.rst @@ -0,0 +1 @@ +|commands| Implement :func:`~disnake.ext.commands.app_check` and :func:`~disnake.ext.commands.app_check_any` decorators. diff --git a/disnake/ext/commands/_types.py b/disnake/ext/commands/_types.py index eb90ee0a42..c6b0a26ece 100644 --- a/disnake/ext/commands/_types.py +++ b/disnake/ext/commands/_types.py @@ -3,6 +3,8 @@ from typing import TYPE_CHECKING, Any, Callable, Coroutine, TypeVar, Union if TYPE_CHECKING: + from disnake import ApplicationCommandInteraction + from .cog import Cog from .context import Context from .errors import CommandError @@ -16,6 +18,10 @@ Check = Union[ Callable[["Cog", "Context[Any]"], MaybeCoro[bool]], Callable[["Context[Any]"], MaybeCoro[bool]] ] +AppCheck = Union[ + Callable[["Cog", "ApplicationCommandInteraction"], MaybeCoro[bool]], + Callable[["ApplicationCommandInteraction"], MaybeCoro[bool]], +] Hook = Union[Callable[["Cog", "Context[Any]"], Coro[Any]], Callable[["Context[Any]"], Coro[Any]]] Error = Union[ Callable[["Cog", "Context[Any]", "CommandError"], Coro[Any]], diff --git a/disnake/ext/commands/base_core.py b/disnake/ext/commands/base_core.py index c21cc5f2d1..7198394be8 100644 --- a/disnake/ext/commands/base_core.py +++ b/disnake/ext/commands/base_core.py @@ -33,7 +33,7 @@ from disnake.interactions import ApplicationCommandInteraction - from ._types import Check, Coro, Error, Hook + from ._types import AppCheck, Coro, Error, Hook from .cog import Cog ApplicationCommandInteractionT = TypeVar( @@ -155,7 +155,7 @@ def __init__(self, func: CommandCallback, *, name: Optional[str] = None, **kwarg except AttributeError: checks = kwargs.get("checks", []) - self.checks: List[Check] = checks + self.checks: List[AppCheck] = checks try: cooldown = func.__commands_cooldown__ @@ -253,10 +253,10 @@ def default_member_permissions(self) -> Optional[Permissions]: def callback(self) -> CommandCallback: return self._callback - def add_check(self, func: Check) -> None: + def add_check(self, func: AppCheck) -> None: """Adds a check to the application command. - This is the non-decorator interface to :func:`.check`. + This is the non-decorator interface to :func:`.app_check`. Parameters ---------- @@ -265,7 +265,7 @@ def add_check(self, func: Check) -> None: """ self.checks.append(func) - def remove_check(self, func: Check) -> None: + def remove_check(self, func: AppCheck) -> None: """Removes a check from the application command. This function is idempotent and will not raise an exception diff --git a/disnake/ext/commands/cog.py b/disnake/ext/commands/cog.py index a0305ccea7..01fd59937c 100644 --- a/disnake/ext/commands/cog.py +++ b/disnake/ext/commands/cog.py @@ -789,30 +789,30 @@ def _inject(self, bot: AnyBot) -> Self: # Add application command checks if cls.bot_slash_command_check is not Cog.bot_slash_command_check: - bot.add_app_command_check(self.bot_slash_command_check, slash_commands=True) # type: ignore + bot.add_app_command_check(self.bot_slash_command_check, slash_commands=True) if cls.bot_user_command_check is not Cog.bot_user_command_check: - bot.add_app_command_check(self.bot_user_command_check, user_commands=True) # type: ignore + bot.add_app_command_check(self.bot_user_command_check, user_commands=True) if cls.bot_message_command_check is not Cog.bot_message_command_check: - bot.add_app_command_check(self.bot_message_command_check, message_commands=True) # type: ignore + bot.add_app_command_check(self.bot_message_command_check, message_commands=True) # Add app command one-off checks if cls.bot_slash_command_check_once is not Cog.bot_slash_command_check_once: bot.add_app_command_check( - self.bot_slash_command_check_once, # type: ignore + self.bot_slash_command_check_once, call_once=True, slash_commands=True, ) if cls.bot_user_command_check_once is not Cog.bot_user_command_check_once: bot.add_app_command_check( - self.bot_user_command_check_once, call_once=True, user_commands=True # type: ignore + self.bot_user_command_check_once, call_once=True, user_commands=True ) if cls.bot_message_command_check_once is not Cog.bot_message_command_check_once: bot.add_app_command_check( - self.bot_message_command_check_once, # type: ignore + self.bot_message_command_check_once, call_once=True, message_commands=True, ) @@ -859,32 +859,32 @@ def _eject(self, bot: AnyBot) -> None: # Remove application command checks if cls.bot_slash_command_check is not Cog.bot_slash_command_check: - bot.remove_app_command_check(self.bot_slash_command_check, slash_commands=True) # type: ignore + bot.remove_app_command_check(self.bot_slash_command_check, slash_commands=True) if cls.bot_user_command_check is not Cog.bot_user_command_check: - bot.remove_app_command_check(self.bot_user_command_check, user_commands=True) # type: ignore + bot.remove_app_command_check(self.bot_user_command_check, user_commands=True) if cls.bot_message_command_check is not Cog.bot_message_command_check: - bot.remove_app_command_check(self.bot_message_command_check, message_commands=True) # type: ignore + bot.remove_app_command_check(self.bot_message_command_check, message_commands=True) # Remove app command one-off checks if cls.bot_slash_command_check_once is not Cog.bot_slash_command_check_once: bot.remove_app_command_check( - self.bot_slash_command_check_once, # type: ignore + self.bot_slash_command_check_once, call_once=True, slash_commands=True, ) if cls.bot_user_command_check_once is not Cog.bot_user_command_check_once: bot.remove_app_command_check( - self.bot_user_command_check_once, # type: ignore + self.bot_user_command_check_once, call_once=True, user_commands=True, ) if cls.bot_message_command_check_once is not Cog.bot_message_command_check_once: bot.remove_app_command_check( - self.bot_message_command_check_once, # type: ignore + self.bot_message_command_check_once, call_once=True, message_commands=True, ) diff --git a/disnake/ext/commands/core.py b/disnake/ext/commands/core.py index 669dd60e04..2bb108e966 100644 --- a/disnake/ext/commands/core.py +++ b/disnake/ext/commands/core.py @@ -67,7 +67,7 @@ from disnake.message import Message - from ._types import Check, Coro, CoroFunc, Error, Hook + from ._types import AppCheck, Check, Coro, CoroFunc, Error, Hook __all__ = ( @@ -81,6 +81,8 @@ "has_any_role", "check", "check_any", + "app_check", + "app_check_any", "before_invoke", "after_invoke", "bot_has_role", @@ -1695,6 +1697,9 @@ async def extended_check(ctx): The function returned by ``predicate`` is **always** a coroutine, even if the original function was not a coroutine. + .. note:: + See :func:`.app_check` for this function's application command counterpart. + .. versionchanged:: 1.3 The ``predicate`` attribute was added. @@ -1767,6 +1772,9 @@ def check_any(*checks: Check) -> Callable[[T], T]: The ``predicate`` attribute for this function **is** a coroutine. + .. note:: + See :func:`.app_check_any` for this function's application command counterpart. + .. versionadded:: 1.3 Parameters @@ -1823,6 +1831,46 @@ async def predicate(ctx: AnyContext) -> bool: return check(predicate) +def app_check(predicate: AppCheck) -> Callable[[T], T]: + """Same as :func:`.check`, but for app commands. + + .. versionadded:: 2.10 + + Parameters + ---------- + predicate: Callable[[:class:`disnake.ApplicationCommandInteraction`], :class:`bool`] + The predicate to check if the command should be invoked. + """ + return check(predicate) # type: ignore # impl is the same, typings are different + + +def app_check_any(*checks: AppCheck) -> Callable[[T], T]: + """Same as :func:`.check_any`, but for app commands. + + .. note:: + See :func:`.check_any` for this function's prefix command counterpart. + + .. versionadded:: 2.10 + + Parameters + ---------- + *checks: Callable[[:class:`disnake.ApplicationCommandInteraction`], :class:`bool`] + An argument list of checks that have been decorated with + the :func:`app_check` decorator. + + Raises + ------ + TypeError + A check passed has not been decorated with the :func:`app_check` + decorator. + """ + try: + return check_any(*checks) # type: ignore # impl is the same, typings are different + except TypeError as e: + msg = str(e).replace("commands.check", "commands.app_check") # fix err message + raise TypeError(msg) from None + + def has_role(item: Union[int, str]) -> Callable[[T], T]: """A :func:`.check` that is added that checks if the member invoking the command has the role specified via the name or ID specified. diff --git a/disnake/ext/commands/errors.py b/disnake/ext/commands/errors.py index 25329d9305..cfa4c12f03 100644 --- a/disnake/ext/commands/errors.py +++ b/disnake/ext/commands/errors.py @@ -14,7 +14,7 @@ from disnake.threads import Thread from disnake.types.snowflake import Snowflake, SnowflakeList - from .context import Context + from .context import AnyContext from .cooldowns import BucketType, Cooldown from .flag_converter import Flag @@ -181,7 +181,8 @@ class BadArgument(UserInputError): class CheckFailure(CommandError): - """Exception raised when the predicates in :attr:`.Command.checks` have failed. + """Exception raised when the predicates in :attr:`.Command.checks` or + :attr:`.InvokableApplicationCommand.checks` have failed. This inherits from :exc:`CommandError` """ @@ -190,7 +191,7 @@ class CheckFailure(CommandError): class CheckAnyFailure(CheckFailure): - """Exception raised when all predicates in :func:`check_any` fail. + """Exception raised when all predicates in :func:`check_any` or :func:`app_check_any` fail. This inherits from :exc:`CheckFailure`. @@ -200,13 +201,15 @@ class CheckAnyFailure(CheckFailure): ---------- errors: List[:class:`CheckFailure`] A list of errors that were caught during execution. - checks: List[Callable[[:class:`Context`], :class:`bool`]] + checks: List[Callable[[Union[:class:`Context`, :class:`disnake.ApplicationCommandInteraction`]], :class:`bool`]] A list of check predicates that failed. """ - def __init__(self, checks: List[CheckFailure], errors: List[Callable[[Context], bool]]) -> None: + def __init__( + self, checks: List[CheckFailure], errors: List[Callable[[AnyContext], bool]] + ) -> None: self.checks: List[CheckFailure] = checks - self.errors: List[Callable[[Context], bool]] = errors + self.errors: List[Callable[[AnyContext], bool]] = errors super().__init__("You do not have permission to run this command.") diff --git a/disnake/ext/commands/interaction_bot_base.py b/disnake/ext/commands/interaction_bot_base.py index 25308c3649..5349abeb3e 100644 --- a/disnake/ext/commands/interaction_bot_base.py +++ b/disnake/ext/commands/interaction_bot_base.py @@ -54,7 +54,7 @@ ) from disnake.permissions import Permissions - from ._types import Check, CoroFunc + from ._types import AppCheck, CoroFunc from .base_core import CogT, CommandCallback, InteractionCommandCallback P = ParamSpec("P") @@ -991,7 +991,7 @@ async def on_message_command_error( def add_app_command_check( self, - func: Check, + func: AppCheck, *, call_once: bool = False, slash_commands: bool = False, @@ -1000,8 +1000,8 @@ def add_app_command_check( ) -> None: """Adds a global application command check to the bot. - This is the non-decorator interface to :meth:`.check`, - :meth:`.check_once`, :meth:`.slash_command_check` and etc. + This is the non-decorator interface to :func:`.app_check`, + :meth:`.slash_command_check` and etc. You must specify at least one of the bool parameters, otherwise the check won't be added. @@ -1039,7 +1039,7 @@ def add_app_command_check( def remove_app_command_check( self, - func: Check, + func: AppCheck, *, call_once: bool = False, slash_commands: bool = False, @@ -1060,7 +1060,7 @@ def remove_app_command_check( The function to remove from the global checks. call_once: :class:`bool` Whether the function was added with ``call_once=True`` in - the :meth:`.Bot.add_check` call or using :meth:`.check_once`. + the :meth:`.Bot.add_app_command_check` call. slash_commands: :class:`bool` Whether this check was for slash commands. user_commands: :class:`bool` @@ -1179,7 +1179,7 @@ def decorator( ) -> Callable[[ApplicationCommandInteraction], Any]: # T was used instead of Check to ensure the type matches on return self.add_app_command_check( - func, # type: ignore + func, call_once=call_once, slash_commands=slash_commands, user_commands=user_commands, diff --git a/docs/ext/commands/api/checks.rst b/docs/ext/commands/api/checks.rst index 336b920c14..2ced115197 100644 --- a/docs/ext/commands/api/checks.rst +++ b/docs/ext/commands/api/checks.rst @@ -63,6 +63,12 @@ Functions .. autofunction:: check_any(*checks) :decorator: +.. autofunction:: app_check(predicate) + :decorator: + +.. autofunction:: app_check_any(*checks) + :decorator: + .. autofunction:: has_role(item) :decorator: From 2c85e39f415e9d96867dc317592582b685888ed9 Mon Sep 17 00:00:00 2001 From: shiftinv <8530778+shiftinv@users.noreply.github.com> Date: Thu, 16 Nov 2023 15:37:08 +0100 Subject: [PATCH 5/6] feat(commands): don't parse self/ctx parameter annotations of prefix command callbacks (#847) --- changelog/847.feature.rst | 1 + disnake/ext/commands/core.py | 40 +---------- disnake/ext/commands/help.py | 10 --- disnake/ext/commands/params.py | 30 +++++--- disnake/utils.py | 70 ++++++++++++++++++- tests/ext/commands/test_params.py | 110 +++++++++++++++++------------- tests/test_utils.py | 82 ++++++++++++++++++++++ 7 files changed, 235 insertions(+), 108 deletions(-) create mode 100644 changelog/847.feature.rst diff --git a/changelog/847.feature.rst b/changelog/847.feature.rst new file mode 100644 index 0000000000..7418ed0783 --- /dev/null +++ b/changelog/847.feature.rst @@ -0,0 +1 @@ +|commands| Skip evaluating annotations of ``self`` (if present) and ``ctx`` parameters in prefix commands. These may now use stringified annotations with types that aren't available at runtime. diff --git a/disnake/ext/commands/core.py b/disnake/ext/commands/core.py index 2bb108e966..fda34b5a95 100644 --- a/disnake/ext/commands/core.py +++ b/disnake/ext/commands/core.py @@ -381,7 +381,7 @@ def callback(self, function: CommandCallback[CogT, Any, P, T]) -> None: except AttributeError: globalns = {} - params = get_signature_parameters(function, globalns) + params = get_signature_parameters(function, globalns, skip_standard_params=True) for param in params.values(): if param.annotation is Greedy: raise TypeError("Unparameterized Greedy[...] is disallowed in signature.") @@ -607,21 +607,7 @@ def clean_params(self) -> Dict[str, inspect.Parameter]: Useful for inspecting signature. """ - result = self.params.copy() - if self.cog is not None: - # first parameter is self - try: - del result[next(iter(result))] - except StopIteration: - raise ValueError("missing 'self' parameter") from None - - try: - # first/second parameter is context - del result[next(iter(result))] - except StopIteration: - raise ValueError("missing 'context' parameter") from None - - return result + return self.params.copy() @property def full_parent_name(self) -> str: @@ -693,27 +679,7 @@ async def _parse_arguments(self, ctx: Context) -> None: kwargs = ctx.kwargs view = ctx.view - iterator = iter(self.params.items()) - - if self.cog is not None: - # we have 'self' as the first parameter so just advance - # the iterator and resume parsing - try: - next(iterator) - except StopIteration: - raise disnake.ClientException( - f'Callback for {self.name} command is missing "self" parameter.' - ) from None - - # next we have the 'ctx' as the next parameter - try: - next(iterator) - except StopIteration: - raise disnake.ClientException( - f'Callback for {self.name} command is missing "ctx" parameter.' - ) from None - - for name, param in iterator: + for name, param in self.params.items(): ctx.current_parameter = param if param.kind in (param.POSITIONAL_OR_KEYWORD, param.POSITIONAL_ONLY): transformed = await self.transform(ctx, param) diff --git a/disnake/ext/commands/help.py b/disnake/ext/commands/help.py index 5841a8ba11..483d4f4bd2 100644 --- a/disnake/ext/commands/help.py +++ b/disnake/ext/commands/help.py @@ -202,16 +202,6 @@ async def _parse_arguments(self, ctx) -> None: async def _on_error_cog_implementation(self, dummy, ctx, error) -> None: await self._injected.on_help_command_error(ctx, error) - @property - def clean_params(self): - result = self.params.copy() - try: - del result[next(iter(result))] - except StopIteration: - raise ValueError("Missing context parameter") from None - else: - return result - def _inject_into_cog(self, cog) -> None: # Warning: hacky diff --git a/disnake/ext/commands/params.py b/disnake/ext/commands/params.py index 5aae2de611..95679ed802 100644 --- a/disnake/ext/commands/params.py +++ b/disnake/ext/commands/params.py @@ -42,7 +42,12 @@ from disnake.ext import commands from disnake.i18n import Localized from disnake.interactions import ApplicationCommandInteraction -from disnake.utils import get_signature_parameters, get_signature_return, maybe_coroutine +from disnake.utils import ( + get_signature_parameters, + get_signature_return, + maybe_coroutine, + signature_has_self_param, +) from . import errors from .converter import CONVERTER_MAPPING @@ -771,7 +776,7 @@ def parse_converter_annotation(self, converter: Callable, fallback_annotation: A # (we need `__call__` here to get the correct global namespace later, since # classes do not have `__globals__`) converter_func = converter.__call__ - _, parameters = isolate_self(get_signature_parameters(converter_func)) + _, parameters = isolate_self(converter_func) if len(parameters) != 1: raise TypeError( @@ -879,9 +884,16 @@ def safe_call(function: Callable[..., T], /, *possible_args: Any, **possible_kwa def isolate_self( - parameters: Dict[str, inspect.Parameter], + function: Callable, + parameters: Optional[Dict[str, inspect.Parameter]] = None, ) -> Tuple[Tuple[Optional[inspect.Parameter], ...], Dict[str, inspect.Parameter]]: - """Create parameters without self and the first interaction""" + """Create parameters without self and the first interaction. + + Optionally accepts a `{str: inspect.Parameter}` dict as an optimization, + calls `get_signature_parameters(function)` if not provided. + """ + if parameters is None: + parameters = get_signature_parameters(function) if not parameters: return (None, None), {} @@ -891,7 +903,7 @@ def isolate_self( cog_param: Optional[inspect.Parameter] = None inter_param: Optional[inspect.Parameter] = None - if parametersl[0].name == "self": + if signature_has_self_param(function): cog_param = parameters.pop(parametersl[0].name) parametersl.pop(0) if parametersl: @@ -941,15 +953,11 @@ def collect_params( ) -> Tuple[Optional[str], Optional[str], List[ParamInfo], Dict[str, Injection]]: """Collect all parameters in a function. - Optionally accepts a `{str: inspect.Parameter}` dict as an optimization, - calls `get_signature_parameters(function)` if not provided. + Optionally accepts a `{str: inspect.Parameter}` dict as an optimization. Returns: (`cog parameter`, `interaction parameter`, `param infos`, `injections`) """ - if parameters is None: - parameters = get_signature_parameters(function) - - (cog_param, inter_param), parameters = isolate_self(parameters) + (cog_param, inter_param), parameters = isolate_self(function, parameters) doc = disnake.utils.parse_docstring(function)["params"] diff --git a/disnake/utils.py b/disnake/utils.py index d40cd4e8fe..a74d50ab94 100644 --- a/disnake/utils.py +++ b/disnake/utils.py @@ -12,6 +12,7 @@ import pkgutil import re import sys +import types import unicodedata import warnings from base64 import b64encode @@ -1227,7 +1228,10 @@ def _get_function_globals(function: Callable[..., Any]) -> Dict[str, Any]: def get_signature_parameters( - function: Callable[..., Any], globalns: Optional[Dict[str, Any]] = None + function: Callable[..., Any], + globalns: Optional[Dict[str, Any]] = None, + *, + skip_standard_params: bool = False, ) -> Dict[str, inspect.Parameter]: # if no globalns provided, unwrap (where needed) and get global namespace from there if globalns is None: @@ -1237,9 +1241,23 @@ def get_signature_parameters( cache: Dict[str, Any] = {} signature = inspect.signature(function) + iterator = iter(signature.parameters.items()) + + if skip_standard_params: + # skip `self` (if present) and `ctx` parameters, + # since their annotations are irrelevant + skip = 2 if signature_has_self_param(function) else 1 + + for _ in range(skip): + try: + next(iterator) + except StopIteration: + raise ValueError( + f"Expected command callback to have at least {skip} parameter(s)" + ) from None # eval all parameter annotations - for name, parameter in signature.parameters.items(): + for name, parameter in iterator: annotation = parameter.annotation if annotation is _inspect_empty: params[name] = parameter @@ -1270,6 +1288,54 @@ def get_signature_return(function: Callable[..., Any]) -> Any: return ret +def signature_has_self_param(function: Callable[..., Any]) -> bool: + # If a function was defined in a class and is not bound (i.e. is not types.MethodType), + # it should have a `self` parameter. + # Bound methods technically also have a `self` parameter, but this is + # used in conjunction with `inspect.signature`, which drops that parameter. + # + # There isn't really any way to reliably detect whether a function + # was defined in a class, other than `__qualname__`, thanks to PEP 3155. + # As noted in the PEP, this doesn't work with rebinding, but that should be a pretty rare edge case. + # + # + # There are a few possible situations here - for the purposes of this method, + # we want to detect the first case only: + # (1) The preceding component for *methods in classes* will be the class name, resulting in `Clazz.func`. + # (2) For *unbound* functions (not methods), `__qualname__ == __name__`. + # (3) Bound methods (i.e. types.MethodType) don't have a `self` parameter in the context of this function (see first paragraph). + # (we currently don't expect to handle bound methods anywhere, except the default help command implementation). + # (4) A somewhat special case are lambdas defined in a class namespace (but not inside a method), which use `Clazz.` and shouldn't match (1). + # (lambdas at class level are a bit funky; we currently only expect them in the `Param(converter=)` kwarg, which doesn't take a `self` parameter). + # (5) Similarly, *nested functions* use `containing_func..func` and shouldn't have a `self` parameter. + # + # Working solely based on this string is certainly not ideal, + # but the compiler does a bunch of processing just for that attribute, + # and there's really no other way to retrieve this information through other means later. + # (3.10: https://github.com/python/cpython/blob/e07086db03d2dc1cd2e2a24f6c9c0ddd422b4cf0/Python/compile.c#L744) + # + # Not reliable for classmethod/staticmethod. + + qname = function.__qualname__ + if qname == function.__name__: + # (2) + return False + + if isinstance(function, types.MethodType): + # (3) + return False + + # "a.b.c.d" => "a.b.c", "d" + parent, basename = qname.rsplit(".", 1) + + if basename == "": + # (4) + return False + + # (5) + return not parent.endswith(".") + + TimestampStyle = Literal["f", "F", "d", "D", "t", "T", "R"] diff --git a/tests/ext/commands/test_params.py b/tests/ext/commands/test_params.py index 61c812c8f0..96f2c08c32 100644 --- a/tests/ext/commands/test_params.py +++ b/tests/ext/commands/test_params.py @@ -10,7 +10,6 @@ import disnake from disnake import Member, Role, User from disnake.ext import commands -from disnake.ext.commands import params OptionType = disnake.OptionType @@ -67,53 +66,6 @@ async def test_verify_type__invalid_member(self, annotation, arg_types) -> None: with pytest.raises(commands.errors.MemberNotFound): await info.verify_type(mock.Mock(), arg_mock) - def test_isolate_self(self) -> None: - def func(a: int) -> None: - ... - - (cog, inter), parameters = params.isolate_self(params.get_signature_parameters(func)) - assert cog is None - assert inter is None - assert parameters == ({"a": mock.ANY}) - - def test_isolate_self_inter(self) -> None: - def func(i: disnake.ApplicationCommandInteraction, a: int) -> None: - ... - - (cog, inter), parameters = params.isolate_self(params.get_signature_parameters(func)) - assert cog is None - assert inter is not None - assert parameters == ({"a": mock.ANY}) - - def test_isolate_self_cog_inter(self) -> None: - def func(self, i: disnake.ApplicationCommandInteraction, a: int) -> None: - ... - - (cog, inter), parameters = params.isolate_self(params.get_signature_parameters(func)) - assert cog is not None - assert inter is not None - assert parameters == ({"a": mock.ANY}) - - def test_isolate_self_generic(self) -> None: - def func(i: disnake.ApplicationCommandInteraction[commands.Bot], a: int) -> None: - ... - - (cog, inter), parameters = params.isolate_self(params.get_signature_parameters(func)) - assert cog is None - assert inter is not None - assert parameters == ({"a": mock.ANY}) - - def test_isolate_self_union(self) -> None: - def func( - i: Union[commands.Context, disnake.ApplicationCommandInteraction[commands.Bot]], a: int - ) -> None: - ... - - (cog, inter), parameters = params.isolate_self(params.get_signature_parameters(func)) - assert cog is None - assert inter is not None - assert parameters == ({"a": mock.ANY}) - # this uses `Range` for testing `_BaseRange`, `String` should work equally class TestBaseRange: @@ -260,3 +212,65 @@ def test_optional(self, annotation_str) -> None: assert info.min_value == 1 assert info.max_value == 2 assert info.type == int + + +class TestIsolateSelf: + def test_function_simple(self) -> None: + def func(a: int) -> None: + ... + + (cog, inter), params = commands.params.isolate_self(func) + assert cog is None + assert inter is None + assert params.keys() == {"a"} + + def test_function_inter(self) -> None: + def func(inter: disnake.ApplicationCommandInteraction, a: int) -> None: + ... + + (cog, inter), params = commands.params.isolate_self(func) + assert cog is None # should not be set + assert inter is not None + assert params.keys() == {"a"} + + def test_unbound_method(self) -> None: + class Cog(commands.Cog): + def func(self, inter: disnake.ApplicationCommandInteraction, a: int) -> None: + ... + + (cog, inter), params = commands.params.isolate_self(Cog.func) + assert cog is not None # *should* be set here + assert inter is not None + assert params.keys() == {"a"} + + # I don't think the param parsing logic ever handles bound methods, but testing for regressions anyway + def test_bound_method(self) -> None: + class Cog(commands.Cog): + def func(self, inter: disnake.ApplicationCommandInteraction, a: int) -> None: + ... + + (cog, inter), params = commands.params.isolate_self(Cog().func) + assert cog is None # should not be set here, since method is already bound + assert inter is not None + assert params.keys() == {"a"} + + def test_generic(self) -> None: + def func(inter: disnake.ApplicationCommandInteraction[commands.Bot], a: int) -> None: + ... + + (cog, inter), params = commands.params.isolate_self(func) + assert cog is None + assert inter is not None + assert params.keys() == {"a"} + + def test_inter_union(self) -> None: + def func( + inter: Union[commands.Context, disnake.ApplicationCommandInteraction[commands.Bot]], + a: int, + ) -> None: + ... + + (cog, inter), params = commands.params.isolate_self(func) + assert cog is None + assert inter is not None + assert params.keys() == {"a"} diff --git a/tests/test_utils.py b/tests/test_utils.py index a8f52e6b1f..d767264a95 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,6 +2,7 @@ import asyncio import datetime +import functools import inspect import os import sys @@ -880,3 +881,84 @@ def test_as_valid_locale(locale, expected) -> None: ) def test_humanize_list(values, expected) -> None: assert utils.humanize_list(values, "plus") == expected + + +# used for `test_signature_has_self_param` +def _toplevel(): + def inner() -> None: + ... + + return inner + + +def decorator(f): + @functools.wraps(f) + def wrap(self, *args, **kwargs): + return f(self, *args, **kwargs) + + return wrap + + +# used for `test_signature_has_self_param` +class _Clazz: + def func(self): + def inner() -> None: + ... + + return inner + + @classmethod + def cmethod(cls) -> None: + ... + + @staticmethod + def smethod() -> None: + ... + + class Nested: + def func(self): + def inner() -> None: + ... + + return inner + + rebind = _toplevel + + @decorator + def decorated(self) -> None: + ... + + _lambda = lambda: None + + +@pytest.mark.parametrize( + ("function", "expected"), + [ + # top-level function + (_toplevel, False), + # methods in class + (_Clazz.func, True), + (_Clazz().func, False), + # unfortunately doesn't work + (_Clazz.rebind, False), + (_Clazz().rebind, False), + # classmethod/staticmethod isn't supported, but checked to ensure consistency + (_Clazz.cmethod, False), + (_Clazz.smethod, True), + # nested class methods + (_Clazz.Nested.func, True), + (_Clazz.Nested().func, False), + # inner methods + (_toplevel(), False), + (_Clazz().func(), False), + (_Clazz.Nested().func(), False), + # decorated method + (_Clazz.decorated, True), + (_Clazz().decorated, False), + # lambda (class-level) + (_Clazz._lambda, False), + (_Clazz()._lambda, False), + ], +) +def test_signature_has_self_param(function, expected) -> None: + assert utils.signature_has_self_param(function) == expected From 4bd0d25ac787adb405d270ad6236245f82a1fd07 Mon Sep 17 00:00:00 2001 From: shiftinv <8530778+shiftinv@users.noreply.github.com> Date: Fri, 17 Nov 2023 20:09:17 +0100 Subject: [PATCH 6/6] build(deps): update pyright to v1.1.336 (#1122) --- disnake/abc.py | 2 +- disnake/activity.py | 2 +- disnake/asset.py | 2 +- disnake/audit_logs.py | 2 +- disnake/channel.py | 12 ++--- disnake/client.py | 32 ++++++++--- disnake/components.py | 48 +++++++++++------ disnake/emoji.py | 2 +- disnake/enums.py | 4 +- disnake/ext/commands/base_core.py | 2 +- disnake/ext/commands/bot_base.py | 17 ++---- disnake/ext/commands/converter.py | 6 +-- disnake/ext/commands/cooldowns.py | 2 +- disnake/ext/commands/core.py | 4 +- disnake/ext/commands/help.py | 6 ++- disnake/ext/commands/params.py | 7 +-- disnake/ext/commands/slash_core.py | 4 +- disnake/ext/tasks/__init__.py | 4 +- disnake/gateway.py | 2 +- disnake/guild.py | 4 -- disnake/http.py | 25 ++++----- disnake/i18n.py | 2 +- disnake/interactions/base.py | 2 +- disnake/iterators.py | 10 ++-- disnake/message.py | 7 +-- disnake/shard.py | 3 +- disnake/state.py | 8 ++- disnake/types/audit_log.py | 84 ++++++++++++++--------------- disnake/types/automod.py | 4 +- disnake/types/template.py | 2 +- disnake/ui/action_row.py | 5 +- disnake/ui/button.py | 2 +- disnake/ui/item.py | 2 +- disnake/ui/modal.py | 2 +- disnake/ui/select/channel.py | 2 +- disnake/ui/select/mentionable.py | 2 +- disnake/ui/select/role.py | 2 +- disnake/ui/select/string.py | 2 +- disnake/ui/select/user.py | 2 +- disnake/utils.py | 12 +++-- disnake/voice_client.py | 2 +- docs/extensions/builder.py | 2 +- examples/basic_voice.py | 4 +- examples/interactions/injections.py | 2 +- examples/interactions/modal.py | 2 + pyproject.toml | 2 +- test_bot/cogs/modals.py | 2 +- tests/ui/test_decorators.py | 8 +-- 48 files changed, 193 insertions(+), 175 deletions(-) diff --git a/disnake/abc.py b/disnake/abc.py index 605bb725aa..b9a60f3ee5 100644 --- a/disnake/abc.py +++ b/disnake/abc.py @@ -390,7 +390,7 @@ async def _edit( if p_id is not None and (parent := self.guild.get_channel(p_id)): overwrites_payload = [c._asdict() for c in parent._overwrites] - if overwrites is not MISSING and overwrites is not None: + if overwrites not in (MISSING, None): overwrites_payload = [] for target, perm in overwrites.items(): if not isinstance(perm, PermissionOverwrite): diff --git a/disnake/activity.py b/disnake/activity.py index 92460cd35d..3c290edd17 100644 --- a/disnake/activity.py +++ b/disnake/activity.py @@ -921,7 +921,7 @@ def create_activity( elif game_type is ActivityType.listening and "sync_id" in data and "session_id" in data: activity = Spotify(**data) else: - activity = Activity(**data) + activity = Activity(**data) # type: ignore if isinstance(activity, (Activity, CustomActivity)) and activity.emoji and state: activity.emoji._state = state diff --git a/disnake/asset.py b/disnake/asset.py index fad72c79ce..bc7b505697 100644 --- a/disnake/asset.py +++ b/disnake/asset.py @@ -24,7 +24,7 @@ ValidAssetFormatTypes = Literal["webp", "jpeg", "jpg", "png", "gif"] AnyState = Union[ConnectionState, _WebhookState[BaseWebhook]] -AssetBytes = Union[bytes, "AssetMixin"] +AssetBytes = Union[utils._BytesLike, "AssetMixin"] VALID_STATIC_FORMATS = frozenset({"jpeg", "jpg", "webp", "png"}) VALID_ASSET_FORMATS = VALID_STATIC_FORMATS | {"gif"} diff --git a/disnake/audit_logs.py b/disnake/audit_logs.py index e8ab022edf..256aaa04dc 100644 --- a/disnake/audit_logs.py +++ b/disnake/audit_logs.py @@ -245,7 +245,7 @@ def _transform_datetime(entry: AuditLogEntry, data: Optional[str]) -> Optional[d def _transform_privacy_level( - entry: AuditLogEntry, data: int + entry: AuditLogEntry, data: Optional[int] ) -> Optional[Union[enums.StagePrivacyLevel, enums.GuildScheduledEventPrivacyLevel]]: if data is None: return None diff --git a/disnake/channel.py b/disnake/channel.py index 7eef52b942..ffb11f2d2c 100644 --- a/disnake/channel.py +++ b/disnake/channel.py @@ -473,7 +473,7 @@ async def edit( overwrites=overwrites, flags=flags, reason=reason, - **kwargs, + **kwargs, # type: ignore ) if payload is not None: # the payload will always be the proper channel payload @@ -1628,7 +1628,7 @@ async def edit( slowmode_delay=slowmode_delay, flags=flags, reason=reason, - **kwargs, + **kwargs, # type: ignore ) if payload is not None: # the payload will always be the proper channel payload @@ -2453,7 +2453,7 @@ async def edit( flags=flags, slowmode_delay=slowmode_delay, reason=reason, - **kwargs, + **kwargs, # type: ignore ) if payload is not None: # the payload will always be the proper channel payload @@ -2946,7 +2946,7 @@ async def edit( overwrites=overwrites, flags=flags, reason=reason, - **kwargs, + **kwargs, # type: ignore ) if payload is not None: # the payload will always be the proper channel payload @@ -3619,7 +3619,7 @@ async def edit( default_sort_order=default_sort_order, default_layout=default_layout, reason=reason, - **kwargs, + **kwargs, # type: ignore ) if payload is not None: # the payload will always be the proper channel payload @@ -3994,7 +3994,7 @@ async def create_thread( stickers=stickers, ) - if auto_archive_duration is not None: + if auto_archive_duration not in (MISSING, None): auto_archive_duration = cast( "ThreadArchiveDurationLiteral", try_enum_to_int(auto_archive_duration) ) diff --git a/disnake/client.py b/disnake/client.py index f71842c7b3..b25b44cbd9 100644 --- a/disnake/client.py +++ b/disnake/client.py @@ -25,6 +25,7 @@ Optional, Sequence, Tuple, + TypedDict, TypeVar, Union, overload, @@ -79,6 +80,8 @@ from .widget import Widget if TYPE_CHECKING: + from typing_extensions import NotRequired + from .abc import GuildChannel, PrivateChannel, Snowflake, SnowflakeTime from .app_commands import APIApplicationCommand from .asset import AssetBytes @@ -207,6 +210,17 @@ class GatewayParams(NamedTuple): zlib: bool = True +# used for typing the ws parameter dict in the connect() loop +class _WebSocketParams(TypedDict): + initial: bool + shard_id: Optional[int] + gateway: Optional[str] + + sequence: NotRequired[Optional[int]] + resume: NotRequired[bool] + session: NotRequired[Optional[str]] + + class Client: """Represents a client connection that connects to Discord. This class is used to interact with the Discord WebSocket and API. @@ -1080,7 +1094,7 @@ async def connect( if not ignore_session_start_limit and self.session_start_limit.remaining == 0: raise SessionStartLimitReached(self.session_start_limit) - ws_params = { + ws_params: _WebSocketParams = { "initial": True, "shard_id": self.shard_id, "gateway": initial_gateway, @@ -1104,6 +1118,7 @@ async def connect( while True: await self.ws.poll_event() + except ReconnectWebSocket as e: _log.info("Got a request to %s the websocket.", e.op) self.dispatch("disconnect") @@ -1116,6 +1131,7 @@ async def connect( gateway=self.ws.resume_gateway if e.resume else initial_gateway, ) continue + except ( OSError, HTTPException, @@ -1196,7 +1212,8 @@ async def close(self) -> None: # if an error happens during disconnects, disregard it. pass - if self.ws is not None and self.ws.open: + # can be None if not connected + if self.ws is not None and self.ws.open: # pyright: ignore[reportUnnecessaryComparison] await self.ws.close(code=1000) await self.http.close() @@ -1874,16 +1891,15 @@ async def change_presence( await self.ws.change_presence(activity=activity, status=status_str) + activities = () if activity is None else (activity,) for guild in self._connection.guilds: me = guild.me - if me is None: + if me is None: # pyright: ignore[reportUnnecessaryComparison] + # may happen if guild is unavailable continue - if activity is not None: - me.activities = (activity,) # type: ignore - else: - me.activities = () - + # Member.activities is typehinted as Tuple[ActivityType, ...], we may be setting it as Tuple[BaseActivity, ...] + me.activities = activities # type: ignore me.status = status # Guild stuff diff --git a/disnake/components.py b/disnake/components.py index e6f3d14904..7614fd424b 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -9,6 +9,7 @@ Dict, Generic, List, + Literal, Optional, Tuple, Type, @@ -22,11 +23,12 @@ from .utils import MISSING, assert_never, get_slots if TYPE_CHECKING: - from typing_extensions import Self + from typing_extensions import Self, TypeAlias from .emoji import Emoji from .types.components import ( ActionRow as ActionRowPayload, + AnySelectMenu as AnySelectMenuPayload, BaseSelectMenu as BaseSelectMenuPayload, ButtonComponent as ButtonComponentPayload, ChannelSelectMenu as ChannelSelectMenuPayload, @@ -63,12 +65,16 @@ "MentionableSelectMenu", "ChannelSelectMenu", ] -MessageComponent = Union["Button", "AnySelectMenu"] -if TYPE_CHECKING: # TODO: remove when we add modal select support - from typing_extensions import TypeAlias +SelectMenuType = Literal[ + ComponentType.string_select, + ComponentType.user_select, + ComponentType.role_select, + ComponentType.mentionable_select, + ComponentType.channel_select, +] -# ModalComponent = Union["TextInput", "AnySelectMenu"] +MessageComponent = Union["Button", "AnySelectMenu"] ModalComponent: TypeAlias = "TextInput" NestedComponent = Union[MessageComponent, ModalComponent] @@ -131,8 +137,6 @@ class ActionRow(Component, Generic[ComponentT]): Attributes ---------- - type: :class:`ComponentType` - The type of component. children: List[Union[:class:`Button`, :class:`BaseSelectMenu`, :class:`TextInput`]] The children components that this holds, if any. """ @@ -142,10 +146,9 @@ class ActionRow(Component, Generic[ComponentT]): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ def __init__(self, data: ActionRowPayload) -> None: - self.type: ComponentType = try_enum(ComponentType, data["type"]) - self.children: List[ComponentT] = [ - _component_factory(d) for d in data.get("components", []) - ] + self.type: Literal[ComponentType.action_row] = ComponentType.action_row + children = [_component_factory(d) for d in data.get("components", [])] + self.children: List[ComponentT] = children # type: ignore def to_dict(self) -> ActionRowPayload: return { @@ -195,7 +198,7 @@ class Button(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ def __init__(self, data: ButtonComponentPayload) -> None: - self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.type: Literal[ComponentType.button] = ComponentType.button self.style: ButtonStyle = try_enum(ButtonStyle, data["style"]) self.custom_id: Optional[str] = data.get("custom_id") self.url: Optional[str] = data.get("url") @@ -209,7 +212,7 @@ def __init__(self, data: ButtonComponentPayload) -> None: def to_dict(self) -> ButtonComponentPayload: payload: ButtonComponentPayload = { - "type": 2, + "type": self.type.value, "style": self.style.value, "disabled": self.disabled, } @@ -273,8 +276,13 @@ class BaseSelectMenu(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__(self, data: BaseSelectMenuPayload) -> None: - self.type: ComponentType = try_enum(ComponentType, data["type"]) + # n.b: ideally this would be `BaseSelectMenuPayload`, + # but pyright made TypedDict keys invariant and doesn't + # fully support readonly items yet (which would help avoid this) + def __init__(self, data: AnySelectMenuPayload) -> None: + component_type = try_enum(ComponentType, data["type"]) + self.type: SelectMenuType = component_type # type: ignore + self.custom_id: str = data["custom_id"] self.placeholder: Optional[str] = data.get("placeholder") self.min_values: int = data.get("min_values", 1) @@ -329,6 +337,7 @@ class StringSelectMenu(BaseSelectMenu): __slots__: Tuple[str, ...] = ("options",) __repr_info__: ClassVar[Tuple[str, ...]] = BaseSelectMenu.__repr_info__ + __slots__ + type: Literal[ComponentType.string_select] def __init__(self, data: StringSelectMenuPayload) -> None: super().__init__(data) @@ -372,6 +381,8 @@ class UserSelectMenu(BaseSelectMenu): __slots__: Tuple[str, ...] = () + type: Literal[ComponentType.user_select] + if TYPE_CHECKING: def to_dict(self) -> UserSelectMenuPayload: @@ -405,6 +416,8 @@ class RoleSelectMenu(BaseSelectMenu): __slots__: Tuple[str, ...] = () + type: Literal[ComponentType.role_select] + if TYPE_CHECKING: def to_dict(self) -> RoleSelectMenuPayload: @@ -438,6 +451,8 @@ class MentionableSelectMenu(BaseSelectMenu): __slots__: Tuple[str, ...] = () + type: Literal[ComponentType.mentionable_select] + if TYPE_CHECKING: def to_dict(self) -> MentionableSelectMenuPayload: @@ -475,6 +490,7 @@ class ChannelSelectMenu(BaseSelectMenu): __slots__: Tuple[str, ...] = ("channel_types",) __repr_info__: ClassVar[Tuple[str, ...]] = BaseSelectMenu.__repr_info__ + __slots__ + type: Literal[ComponentType.channel_select] def __init__(self, data: ChannelSelectMenuPayload) -> None: super().__init__(data) @@ -643,7 +659,7 @@ class TextInput(Component): def __init__(self, data: TextInputPayload) -> None: style = data.get("style", TextInputStyle.short.value) - self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.type: Literal[ComponentType.text_input] = ComponentType.text_input self.custom_id: str = data["custom_id"] self.style: TextInputStyle = try_enum(TextInputStyle, style) self.label: Optional[str] = data.get("label") diff --git a/disnake/emoji.py b/disnake/emoji.py index 0f3d02c27d..2a24877b07 100644 --- a/disnake/emoji.py +++ b/disnake/emoji.py @@ -151,7 +151,7 @@ def roles(self) -> List[Role]: and count towards a separate limit of 25 emojis. """ guild = self.guild - if guild is None: + if guild is None: # pyright: ignore[reportUnnecessaryComparison] return [] return [role for role in guild.roles if self._roles.has(role.id)] diff --git a/disnake/enums.py b/disnake/enums.py index b4bf3d994d..cb603c5425 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -466,7 +466,7 @@ def category(self) -> Optional[AuditLogActionCategory]: @property def target_type(self) -> Optional[str]: v = self.value - if v == -1: + if v == -1: # pyright: ignore[reportUnnecessaryComparison] return "all" elif v < 10: return "guild" @@ -627,7 +627,7 @@ class ComponentType(Enum): action_row = 1 button = 2 string_select = 3 - select = string_select # backwards compatibility + select = 3 # backwards compatibility text_input = 4 user_select = 5 role_select = 6 diff --git a/disnake/ext/commands/base_core.py b/disnake/ext/commands/base_core.py index 7198394be8..3599ea0908 100644 --- a/disnake/ext/commands/base_core.py +++ b/disnake/ext/commands/base_core.py @@ -303,7 +303,7 @@ def _prepare_cooldowns(self, inter: ApplicationCommandInteraction) -> None: dt = inter.created_at current = dt.replace(tzinfo=datetime.timezone.utc).timestamp() bucket = self._buckets.get_bucket(inter, current) # type: ignore - if bucket is not None: + if bucket is not None: # pyright: ignore[reportUnnecessaryComparison] retry_after = bucket.update_rate_limit(current) if retry_after: raise CommandOnCooldown(bucket, retry_after, self._buckets.type) # type: ignore diff --git a/disnake/ext/commands/bot_base.py b/disnake/ext/commands/bot_base.py index d55dc63490..1bba906c82 100644 --- a/disnake/ext/commands/bot_base.py +++ b/disnake/ext/commands/bot_base.py @@ -10,18 +10,7 @@ import sys import traceback import warnings -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Iterable, - List, - Optional, - Type, - TypeVar, - Union, - cast, -) +from typing import TYPE_CHECKING, Any, Callable, Iterable, List, Optional, Type, TypeVar, Union import disnake @@ -414,7 +403,7 @@ def _remove_module_references(self, name: str) -> None: super()._remove_module_references(name) # remove all the commands from the module for cmd in self.all_commands.copy().values(): - if cmd.module is not None and _is_submodule(name, cmd.module): + if cmd.module and _is_submodule(name, cmd.module): if isinstance(cmd, GroupMixin): cmd.recursively_remove_all_commands() self.remove_command(cmd.name) @@ -513,7 +502,7 @@ class be provided, it must be similar enough to :class:`.Context`\'s ``cls`` parameter. """ view = StringView(message.content) - ctx = cast("CXT", cls(prefix=None, view=view, bot=self, message=message)) + ctx = cls(prefix=None, view=view, bot=self, message=message) if message.author.id == self.user.id: # type: ignore return ctx diff --git a/disnake/ext/commands/converter.py b/disnake/ext/commands/converter.py index 8bca2bd6dd..29672b2e54 100644 --- a/disnake/ext/commands/converter.py +++ b/disnake/ext/commands/converter.py @@ -1133,7 +1133,7 @@ def __class_getitem__(cls, params: Union[Tuple[T], T]) -> Greedy[T]: raise TypeError("Greedy[...] expects a type or a Converter instance.") if converter in (str, type(None)) or origin is Greedy: - raise TypeError(f"Greedy[{converter.__name__}] is invalid.") # type: ignore + raise TypeError(f"Greedy[{converter.__name__}] is invalid.") if origin is Union and type(None) in args: raise TypeError(f"Greedy[{converter!r}] is invalid.") @@ -1161,7 +1161,7 @@ def get_converter(param: inspect.Parameter) -> Any: return converter -_GenericAlias = type(List[T]) +_GenericAlias = type(List[Any]) def is_generic_type(tp: Any, *, _GenericAlias: Type = _GenericAlias) -> bool: @@ -1222,7 +1222,7 @@ async def _actual_conversion( raise ConversionError(converter, exc) from exc try: - return converter(argument) + return converter(argument) # type: ignore except CommandError: raise except Exception as exc: diff --git a/disnake/ext/commands/cooldowns.py b/disnake/ext/commands/cooldowns.py index 4268f76fff..354754550a 100644 --- a/disnake/ext/commands/cooldowns.py +++ b/disnake/ext/commands/cooldowns.py @@ -228,7 +228,7 @@ def get_bucket(self, message: Message, current: Optional[float] = None) -> Coold key = self._bucket_key(message) if key not in self._cache: bucket = self.create_bucket(message) - if bucket is not None: + if bucket is not None: # pyright: ignore[reportUnnecessaryComparison] self._cache[key] = bucket else: bucket = self._cache[key] diff --git a/disnake/ext/commands/core.py b/disnake/ext/commands/core.py index fda34b5a95..2ddcb10075 100644 --- a/disnake/ext/commands/core.py +++ b/disnake/ext/commands/core.py @@ -755,7 +755,7 @@ def _prepare_cooldowns(self, ctx: Context) -> None: dt = ctx.message.edited_at or ctx.message.created_at current = dt.replace(tzinfo=datetime.timezone.utc).timestamp() bucket = self._buckets.get_bucket(ctx.message, current) - if bucket is not None: + if bucket is not None: # pyright: ignore[reportUnnecessaryComparison] retry_after = bucket.update_rate_limit(current) if retry_after: raise CommandOnCooldown(bucket, retry_after, self._buckets.type) # type: ignore @@ -1718,7 +1718,7 @@ def decorator(func: Union[Command, CoroFunc]) -> Union[Command, CoroFunc]: decorator.predicate = predicate else: - @functools.wraps(predicate) + @functools.wraps(predicate) # type: ignore async def wrapper(ctx): return predicate(ctx) # type: ignore diff --git a/disnake/ext/commands/help.py b/disnake/ext/commands/help.py index 483d4f4bd2..ecd3988b86 100644 --- a/disnake/ext/commands/help.py +++ b/disnake/ext/commands/help.py @@ -368,7 +368,11 @@ def invoked_with(self): """ command_name = self._command_impl.name ctx = self.context - if ctx is None or ctx.command is None or ctx.command.qualified_name != command_name: + if ( + ctx is disnake.utils.MISSING + or ctx.command is None + or ctx.command.qualified_name != command_name + ): return command_name return ctx.invoked_with diff --git a/disnake/ext/commands/params.py b/disnake/ext/commands/params.py index 95679ed802..9114b8b353 100644 --- a/disnake/ext/commands/params.py +++ b/disnake/ext/commands/params.py @@ -85,9 +85,6 @@ if sys.version_info >= (3, 10): from types import EllipsisType, UnionType -elif TYPE_CHECKING: - UnionType = object() - EllipsisType = ellipsis # noqa: F821 else: UnionType = object() EllipsisType = type(Ellipsis) @@ -543,7 +540,7 @@ def __init__( self.max_length = max_length self.large = large - def copy(self) -> ParamInfo: + def copy(self) -> Self: # n. b. this method needs to be manually updated when a new attribute is added. cls = self.__class__ ins = cls.__new__(cls) @@ -1339,7 +1336,7 @@ def option_enum( choices = choices or kwargs first, *_ = choices.values() - return Enum("", choices, type=type(first)) + return Enum("", choices, type=type(first)) # type: ignore class ConverterMethod(classmethod): diff --git a/disnake/ext/commands/slash_core.py b/disnake/ext/commands/slash_core.py index 1b318a21d0..4652c552f8 100644 --- a/disnake/ext/commands/slash_core.py +++ b/disnake/ext/commands/slash_core.py @@ -666,7 +666,7 @@ async def _call_relevant_autocompleter(self, inter: ApplicationCommandInteractio group = self.children.get(chain[0]) if not isinstance(group, SubCommandGroup): raise AssertionError("the first subcommand is not a SubCommandGroup instance") - subcmd = group.children.get(chain[1]) if group is not None else None + subcmd = group.children.get(chain[1]) else: raise ValueError("Command chain is too long") @@ -695,7 +695,7 @@ async def invoke_children(self, inter: ApplicationCommandInteraction) -> None: group = self.children.get(chain[0]) if not isinstance(group, SubCommandGroup): raise AssertionError("the first subcommand is not a SubCommandGroup instance") - subcmd = group.children.get(chain[1]) if group is not None else None + subcmd = group.children.get(chain[1]) else: raise ValueError("Command chain is too long") diff --git a/disnake/ext/tasks/__init__.py b/disnake/ext/tasks/__init__.py index 1c23e0e912..6532c3d088 100644 --- a/disnake/ext/tasks/__init__.py +++ b/disnake/ext/tasks/__init__.py @@ -708,7 +708,7 @@ class Object(Protocol[T_co, P]): def __new__(cls) -> T_co: ... - def __init__(*args: P.args, **kwargs: P.kwargs) -> None: + def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: ... @@ -734,7 +734,7 @@ def loop( def loop( - cls: Type[Object[L_co, Concatenate[LF, P]]] = Loop[LF], + cls: Type[Object[L_co, Concatenate[LF, P]]] = Loop[Any], **kwargs: Any, ) -> Callable[[LF], L_co]: """A decorator that schedules a task in the background for you with diff --git a/disnake/gateway.py b/disnake/gateway.py index 2081493509..cd0cb6d44a 100644 --- a/disnake/gateway.py +++ b/disnake/gateway.py @@ -274,7 +274,7 @@ async def close(self, *, code: int = 4000, message: bytes = b"") -> bool: class HeartbeatWebSocket(Protocol): - HEARTBEAT: Final[Literal[1, 3]] # type: ignore + HEARTBEAT: Final[Literal[1, 3]] thread_id: int loop: asyncio.AbstractEventLoop diff --git a/disnake/guild.py b/disnake/guild.py index 3927992fb5..ba140f2298 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -3136,10 +3136,6 @@ async def integrations(self) -> List[Integration]: def convert(d): factory, _ = _integration_factory(d["type"]) - if factory is None: - raise InvalidData( - "Unknown integration type {type!r} for integration ID {id}".format_map(d) - ) return factory(guild=self, data=d) return [convert(d) for d in data] diff --git a/disnake/http.py b/disnake/http.py index f8c4b44694..06b3801861 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -248,19 +248,18 @@ def recreate(self) -> None: ) async def ws_connect(self, url: str, *, compress: int = 0) -> aiohttp.ClientWebSocketResponse: - kwargs = { - "proxy_auth": self.proxy_auth, - "proxy": self.proxy, - "max_msg_size": 0, - "timeout": 30.0, - "autoclose": False, - "headers": { + return await self.__session.ws_connect( + url, + proxy_auth=self.proxy_auth, + proxy=self.proxy, + max_msg_size=0, + timeout=30.0, + autoclose=False, + headers={ "User-Agent": self.user_agent, }, - "compress": compress, - } - - return await self.__session.ws_connect(url, **kwargs) + compress=compress, + ) async def request( self, @@ -276,9 +275,7 @@ async def request( lock = self._locks.get(bucket) if lock is None: - lock = asyncio.Lock() - if bucket is not None: - self._locks[bucket] = lock + self._locks[bucket] = lock = asyncio.Lock() # header creation headers: Dict[str, str] = { diff --git a/disnake/i18n.py b/disnake/i18n.py index 344787ad5b..c2781a9eb8 100644 --- a/disnake/i18n.py +++ b/disnake/i18n.py @@ -409,7 +409,7 @@ def _load_file(self, path: Path) -> None: except Exception as e: raise RuntimeError(f"Unable to load '{path}': {e}") from e - def _load_dict(self, data: Dict[str, str], locale: str) -> None: + def _load_dict(self, data: Dict[str, Optional[str]], locale: str) -> None: if not isinstance(data, dict) or not all( o is None or isinstance(o, str) for o in data.values() ): diff --git a/disnake/interactions/base.py b/disnake/interactions/base.py index bdcbe3cae2..01637be96a 100644 --- a/disnake/interactions/base.py +++ b/disnake/interactions/base.py @@ -1855,7 +1855,7 @@ def __init__( guild and guild.get_channel_or_thread(channel_id) or factory( - guild=guild_fallback, # type: ignore + guild=guild_fallback, state=state, data=channel, # type: ignore ) diff --git a/disnake/iterators.py b/disnake/iterators.py index ea8347effd..f7d694598a 100644 --- a/disnake/iterators.py +++ b/disnake/iterators.py @@ -106,7 +106,7 @@ def chunk(self, max_size: int) -> _ChunkedAsyncIterator[T]: def map(self, func: _Func[T, OT]) -> _MappedAsyncIterator[OT]: return _MappedAsyncIterator(self, func) - def filter(self, predicate: _Func[T, bool]) -> _FilteredAsyncIterator[T]: + def filter(self, predicate: Optional[_Func[T, bool]]) -> _FilteredAsyncIterator[T]: return _FilteredAsyncIterator(self, predicate) async def flatten(self) -> List[T]: @@ -152,11 +152,11 @@ async def next(self) -> OT: class _FilteredAsyncIterator(_AsyncIterator[T]): - def __init__(self, iterator: _AsyncIterator[T], predicate: _Func[T, bool]) -> None: + def __init__(self, iterator: _AsyncIterator[T], predicate: Optional[_Func[T, bool]]) -> None: self.iterator = iterator if predicate is None: - predicate = lambda x: bool(x) + predicate = bool # similar to the `filter` builtin, a `None` filter drops falsy items self.predicate: _Func[T, bool] = predicate @@ -626,8 +626,8 @@ async def _fill(self) -> None: } for element in entries: - # TODO: remove this if statement later - if element["action_type"] is None: + # https://github.com/discord/discord-api-docs/issues/5055#issuecomment-1266363766 + if element["action_type"] is None: # pyright: ignore[reportUnnecessaryComparison] continue await self.entries.put( diff --git a/disnake/message.py b/disnake/message.py index 92aba532c7..e3967e1160 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -658,13 +658,14 @@ def __repr__(self) -> str: return f"" def to_dict(self) -> MessageReferencePayload: - result: MessageReferencePayload = {"channel_id": self.channel_id} + result: MessageReferencePayload = { + "channel_id": self.channel_id, + "fail_if_not_exists": self.fail_if_not_exists, + } if self.message_id is not None: result["message_id"] = self.message_id if self.guild_id is not None: result["guild_id"] = self.guild_id - if self.fail_if_not_exists is not None: - result["fail_if_not_exists"] = self.fail_if_not_exists return result to_message_reference_dict = to_dict diff --git a/disnake/shard.py b/disnake/shard.py index 102c66e4ae..a82ae13efd 100644 --- a/disnake/shard.py +++ b/disnake/shard.py @@ -589,7 +589,8 @@ async def change_presence( activities = () if activity is None else (activity,) for guild in guilds: me = guild.me - if me is None: + if me is None: # pyright: ignore[reportUnnecessaryComparison] + # may happen if guild is unavailable continue # Member.activities is typehinted as Tuple[ActivityType, ...], we may be setting it as Tuple[BaseActivity, ...] diff --git a/disnake/state.py b/disnake/state.py index ca915aa33f..714a92759b 100644 --- a/disnake/state.py +++ b/disnake/state.py @@ -25,7 +25,6 @@ Tuple, TypeVar, Union, - cast, overload, ) @@ -600,7 +599,6 @@ def _get_guild_channel( if channel is None: if "author" in data: # MessagePayload - data = cast("MessagePayload", data) user_id = int(data["author"]["id"]) else: # TypingStartEvent @@ -637,8 +635,6 @@ async def query_members( ): guild_id = guild.id ws = self._get_websocket(guild_id) - if ws is None: - raise RuntimeError("Somehow do not have a websocket for this guild_id") request = ChunkRequest(guild.id, self.loop, self._get_guild, cache=cache) self._chunk_requests[request.nonce] = request @@ -1796,6 +1792,8 @@ def parse_voice_server_update(self, data: gateway.VoiceServerUpdateEvent) -> Non logging_coroutine(coro, info="Voice Protocol voice server update handler") ) + # FIXME: this should be refactored. The `GroupChannel` path will never be hit, + # `raw.timestamp` exists so no need to parse it twice, and `.get_user` should be used before falling back def parse_typing_start(self, data: gateway.TypingStartEvent) -> None: channel, guild = self._get_guild_channel(data) raw = RawTypingEvent(data) @@ -1810,7 +1808,7 @@ def parse_typing_start(self, data: gateway.TypingStartEvent) -> None: self.dispatch("raw_typing", raw) - if channel is not None: + if channel is not None: # pyright: ignore[reportUnnecessaryComparison] member = None if raw.member is not None: member = raw.member diff --git a/disnake/types/audit_log.py b/disnake/types/audit_log.py index d3b3a5484f..f9640b3ad9 100644 --- a/disnake/types/audit_log.py +++ b/disnake/types/audit_log.py @@ -103,8 +103,8 @@ class _AuditLogChange_Str(TypedDict): "permissions", "tags", ] - new_value: str - old_value: str + new_value: NotRequired[str] + old_value: NotRequired[str] class _AuditLogChange_AssetHash(TypedDict): @@ -116,8 +116,8 @@ class _AuditLogChange_AssetHash(TypedDict): "avatar_hash", "asset", ] - new_value: str - old_value: str + new_value: NotRequired[str] + old_value: NotRequired[str] class _AuditLogChange_Snowflake(TypedDict): @@ -134,8 +134,8 @@ class _AuditLogChange_Snowflake(TypedDict): "inviter_id", "guild_id", ] - new_value: Snowflake - old_value: Snowflake + new_value: NotRequired[Snowflake] + old_value: NotRequired[Snowflake] class _AuditLogChange_Bool(TypedDict): @@ -157,8 +157,8 @@ class _AuditLogChange_Bool(TypedDict): "premium_progress_bar_enabled", "enabled", ] - new_value: bool - old_value: bool + new_value: NotRequired[bool] + old_value: NotRequired[bool] class _AuditLogChange_Int(TypedDict): @@ -175,104 +175,104 @@ class _AuditLogChange_Int(TypedDict): "auto_archive_duration", "default_auto_archive_duration", ] - new_value: int - old_value: int + new_value: NotRequired[int] + old_value: NotRequired[int] class _AuditLogChange_ListSnowflake(TypedDict): key: Literal["exempt_roles", "exempt_channels"] - new_value: List[Snowflake] - old_value: List[Snowflake] + new_value: NotRequired[List[Snowflake]] + old_value: NotRequired[List[Snowflake]] class _AuditLogChange_ListRole(TypedDict): key: Literal["$add", "$remove"] - new_value: List[Role] - old_value: List[Role] + new_value: NotRequired[List[Role]] + old_value: NotRequired[List[Role]] class _AuditLogChange_MFALevel(TypedDict): key: Literal["mfa_level"] - new_value: MFALevel - old_value: MFALevel + new_value: NotRequired[MFALevel] + old_value: NotRequired[MFALevel] class _AuditLogChange_VerificationLevel(TypedDict): key: Literal["verification_level"] - new_value: VerificationLevel - old_value: VerificationLevel + new_value: NotRequired[VerificationLevel] + old_value: NotRequired[VerificationLevel] class _AuditLogChange_ExplicitContentFilter(TypedDict): key: Literal["explicit_content_filter"] - new_value: ExplicitContentFilterLevel - old_value: ExplicitContentFilterLevel + new_value: NotRequired[ExplicitContentFilterLevel] + old_value: NotRequired[ExplicitContentFilterLevel] class _AuditLogChange_DefaultMessageNotificationLevel(TypedDict): key: Literal["default_message_notifications"] - new_value: DefaultMessageNotificationLevel - old_value: DefaultMessageNotificationLevel + new_value: NotRequired[DefaultMessageNotificationLevel] + old_value: NotRequired[DefaultMessageNotificationLevel] class _AuditLogChange_ChannelType(TypedDict): key: Literal["type"] - new_value: ChannelType - old_value: ChannelType + new_value: NotRequired[ChannelType] + old_value: NotRequired[ChannelType] class _AuditLogChange_IntegrationExpireBehaviour(TypedDict): key: Literal["expire_behavior"] - new_value: IntegrationExpireBehavior - old_value: IntegrationExpireBehavior + new_value: NotRequired[IntegrationExpireBehavior] + old_value: NotRequired[IntegrationExpireBehavior] class _AuditLogChange_VideoQualityMode(TypedDict): key: Literal["video_quality_mode"] - new_value: VideoQualityMode - old_value: VideoQualityMode + new_value: NotRequired[VideoQualityMode] + old_value: NotRequired[VideoQualityMode] class _AuditLogChange_Overwrites(TypedDict): key: Literal["permission_overwrites"] - new_value: List[PermissionOverwrite] - old_value: List[PermissionOverwrite] + new_value: NotRequired[List[PermissionOverwrite]] + old_value: NotRequired[List[PermissionOverwrite]] class _AuditLogChange_Datetime(TypedDict): key: Literal["communication_disabled_until"] - new_value: datetime.datetime - old_value: datetime.datetime + new_value: NotRequired[datetime.datetime] + old_value: NotRequired[datetime.datetime] class _AuditLogChange_ApplicationCommandPermissions(TypedDict): key: str - new_value: ApplicationCommandPermissions - old_value: ApplicationCommandPermissions + new_value: NotRequired[ApplicationCommandPermissions] + old_value: NotRequired[ApplicationCommandPermissions] class _AuditLogChange_AutoModTriggerType(TypedDict): key: Literal["trigger_type"] - new_value: AutoModTriggerType - old_value: AutoModTriggerType + new_value: NotRequired[AutoModTriggerType] + old_value: NotRequired[AutoModTriggerType] class _AuditLogChange_AutoModEventType(TypedDict): key: Literal["event_type"] - new_value: AutoModEventType - old_value: AutoModEventType + new_value: NotRequired[AutoModEventType] + old_value: NotRequired[AutoModEventType] class _AuditLogChange_AutoModActions(TypedDict): key: Literal["actions"] - new_value: List[AutoModAction] - old_value: List[AutoModAction] + new_value: NotRequired[List[AutoModAction]] + old_value: NotRequired[List[AutoModAction]] class _AuditLogChange_AutoModTriggerMetadata(TypedDict): key: Literal["trigger_metadata"] - new_value: AutoModTriggerMetadata - old_value: AutoModTriggerMetadata + new_value: NotRequired[AutoModTriggerMetadata] + old_value: NotRequired[AutoModTriggerMetadata] AuditLogChange = Union[ diff --git a/disnake/types/automod.py b/disnake/types/automod.py index 156952d092..f7ac372e5e 100644 --- a/disnake/types/automod.py +++ b/disnake/types/automod.py @@ -8,9 +8,9 @@ from .snowflake import Snowflake, SnowflakeList -AutoModTriggerType = Literal[1, 2, 3, 4, 5] +AutoModTriggerType = Literal[1, 3, 4, 5] AutoModEventType = Literal[1] -AutoModActionType = Literal[1, 2] +AutoModActionType = Literal[1, 2, 3] AutoModPresetType = Literal[1, 2, 3] diff --git a/disnake/types/template.py b/disnake/types/template.py index ddb2c26cb7..e0008659aa 100644 --- a/disnake/types/template.py +++ b/disnake/types/template.py @@ -20,7 +20,7 @@ class Template(TypedDict): description: Optional[str] usage_count: int creator_id: Snowflake - creator: User + creator: Optional[User] # unsure when this can be null, but the spec says so created_at: str updated_at: str source_guild_id: Snowflake diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index fe7244a776..21ea01cb74 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -159,7 +159,8 @@ def __init__(self: ActionRow[ModalUIComponent], *components: ModalUIComponent) - def __init__(self: ActionRow[StrictUIComponentT], *components: StrictUIComponentT) -> None: ... - def __init__(self, *components: UIComponentT) -> None: + # n.b. this should be `*components: UIComponentT`, but pyright does not like it + def __init__(self, *components: Union[MessageUIComponent, ModalUIComponent]) -> None: self._children: List[UIComponentT] = [] for component in components: @@ -167,7 +168,7 @@ def __init__(self, *components: UIComponentT) -> None: raise TypeError( f"components should be of type WrappedComponent, got {type(component).__name__}." ) - self.append_item(component) + self.append_item(component) # type: ignore def __repr__(self) -> str: return f"" diff --git a/disnake/ui/button.py b/disnake/ui/button.py index d5e1fc7708..a961ba29ab 100644 --- a/disnake/ui/button.py +++ b/disnake/ui/button.py @@ -275,7 +275,7 @@ def button( def button( - cls: Type[Object[B_co, P]] = Button[Any], **kwargs: Any + cls: Type[Object[B_co, ...]] = Button[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[B_co]], DecoratedItem[B_co]]: """A decorator that attaches a button to a component. diff --git a/disnake/ui/item.py b/disnake/ui/item.py index 971ca8dcb3..464eb4d588 100644 --- a/disnake/ui/item.py +++ b/disnake/ui/item.py @@ -184,5 +184,5 @@ class Object(Protocol[T_co, P]): def __new__(cls) -> T_co: ... - def __init__(*args: P.args, **kwargs: P.kwargs) -> None: + def __init__(self, *args: P.args, **kwargs: P.kwargs) -> None: ... diff --git a/disnake/ui/modal.py b/disnake/ui/modal.py index a7a5503a28..adf21ffa9c 100644 --- a/disnake/ui/modal.py +++ b/disnake/ui/modal.py @@ -55,7 +55,7 @@ def __init__( custom_id: str = MISSING, timeout: float = 600, ) -> None: - if timeout is None: + if timeout is None: # pyright: ignore[reportUnnecessaryComparison] raise ValueError("Timeout may not be None") rows = components_to_rows(components) diff --git a/disnake/ui/select/channel.py b/disnake/ui/select/channel.py index a98472b547..57dd9cfbe9 100644 --- a/disnake/ui/select/channel.py +++ b/disnake/ui/select/channel.py @@ -168,7 +168,7 @@ def channel_select( def channel_select( - cls: Type[Object[S_co, P]] = ChannelSelect[Any], **kwargs: Any + cls: Type[Object[S_co, ...]] = ChannelSelect[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: """A decorator that attaches a channel select menu to a component. diff --git a/disnake/ui/select/mentionable.py b/disnake/ui/select/mentionable.py index 4f0d591201..860903f7f1 100644 --- a/disnake/ui/select/mentionable.py +++ b/disnake/ui/select/mentionable.py @@ -144,7 +144,7 @@ def mentionable_select( def mentionable_select( - cls: Type[Object[S_co, P]] = MentionableSelect[Any], **kwargs: Any + cls: Type[Object[S_co, ...]] = MentionableSelect[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: """A decorator that attaches a mentionable (user/member/role) select menu to a component. diff --git a/disnake/ui/select/role.py b/disnake/ui/select/role.py index 69b1bcaa57..fe2da2f97a 100644 --- a/disnake/ui/select/role.py +++ b/disnake/ui/select/role.py @@ -142,7 +142,7 @@ def role_select( def role_select( - cls: Type[Object[S_co, P]] = RoleSelect[Any], **kwargs: Any + cls: Type[Object[S_co, ...]] = RoleSelect[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: """A decorator that attaches a role select menu to a component. diff --git a/disnake/ui/select/string.py b/disnake/ui/select/string.py index d38c9ea6ba..3eeedc1f22 100644 --- a/disnake/ui/select/string.py +++ b/disnake/ui/select/string.py @@ -268,7 +268,7 @@ def string_select( def string_select( - cls: Type[Object[S_co, P]] = StringSelect[Any], **kwargs: Any + cls: Type[Object[S_co, ...]] = StringSelect[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: """A decorator that attaches a string select menu to a component. diff --git a/disnake/ui/select/user.py b/disnake/ui/select/user.py index 179b9d6c74..4868894a83 100644 --- a/disnake/ui/select/user.py +++ b/disnake/ui/select/user.py @@ -143,7 +143,7 @@ def user_select( def user_select( - cls: Type[Object[S_co, P]] = UserSelect[Any], **kwargs: Any + cls: Type[Object[S_co, ...]] = UserSelect[Any], **kwargs: Any ) -> Callable[[ItemCallbackType[S_co]], DecoratedItem[S_co]]: """A decorator that attaches a user select menu to a component. diff --git a/disnake/utils.py b/disnake/utils.py index a74d50ab94..6fa6ae82d7 100644 --- a/disnake/utils.py +++ b/disnake/utils.py @@ -134,6 +134,7 @@ class _RequestLike(Protocol): V = TypeVar("V") T_co = TypeVar("T_co", covariant=True) _Iter = Union[Iterator[T], AsyncIterator[T]] +_BytesLike = Union[bytes, bytearray, memoryview] class CachedSlotProperty(Generic[T, T_co]): @@ -489,7 +490,7 @@ def _maybe_cast(value: V, converter: Callable[[V], T], default: T = None) -> Opt } -def _get_mime_type_for_image(data: bytes) -> str: +def _get_mime_type_for_image(data: _BytesLike) -> str: if data[0:8] == b"\x89\x50\x4E\x47\x0D\x0A\x1A\x0A": return "image/png" elif data[0:3] == b"\xff\xd8\xff" or data[6:10] in (b"JFIF", b"Exif"): @@ -502,14 +503,14 @@ def _get_mime_type_for_image(data: bytes) -> str: raise ValueError("Unsupported image type given") -def _bytes_to_base64_data(data: bytes) -> str: +def _bytes_to_base64_data(data: _BytesLike) -> str: fmt = "data:{mime};base64,{data}" mime = _get_mime_type_for_image(data) b64 = b64encode(data).decode("ascii") return fmt.format(mime=mime, data=b64) -def _get_extension_for_image(data: bytes) -> Optional[str]: +def _get_extension_for_image(data: _BytesLike) -> Optional[str]: try: mime_type = _get_mime_type_for_image(data) except ValueError: @@ -538,7 +539,7 @@ async def _assetbytes_to_base64_data(data: Optional[AssetBytes]) -> Optional[str if HAS_ORJSON: def _to_json(obj: Any) -> str: - return orjson.dumps(obj).decode("utf-8") + return orjson.dumps(obj).decode("utf-8") # type: ignore _from_json = orjson.loads # type: ignore @@ -571,7 +572,8 @@ async def maybe_coroutine( return value # type: ignore # typeguard doesn't narrow in the negative case -async def async_all(gen: Iterable[Union[Awaitable[bool], bool]], *, check=_isawaitable) -> bool: +async def async_all(gen: Iterable[Union[Awaitable[bool], bool]]) -> bool: + check = _isawaitable for elem in gen: if check(elem): elem = await elem diff --git a/disnake/voice_client.py b/disnake/voice_client.py index 52750ecebd..a6cc13e0ba 100644 --- a/disnake/voice_client.py +++ b/disnake/voice_client.py @@ -279,7 +279,7 @@ async def on_voice_server_update(self, data: VoiceServerUpdateEvent) -> None: self.server_id = int(data["guild_id"]) endpoint = data.get("endpoint") - if endpoint is None or self.token is None: + if endpoint is None or not self.token: _log.warning( "Awaiting endpoint... This requires waiting. " "If timeout occurred considering raising the timeout and reconnecting." diff --git a/docs/extensions/builder.py b/docs/extensions/builder.py index 5133af0f85..61e366d2ca 100644 --- a/docs/extensions/builder.py +++ b/docs/extensions/builder.py @@ -65,7 +65,7 @@ def disable_mathjax(app: Sphinx, config: Config) -> None: # inspired by https://github.com/readthedocs/sphinx-hoverxref/blob/003b84fee48262f1a969c8143e63c177bd98aa26/hoverxref/extension.py#L151 for listener in app.events.listeners.get("html-page-context", []): - module_name = inspect.getmodule(listener.handler).__name__ # type: ignore + module_name = inspect.getmodule(listener.handler).__name__ if module_name == "sphinx.ext.mathjax": app.disconnect(listener.id) diff --git a/examples/basic_voice.py b/examples/basic_voice.py index 6d224b21e5..45046c780f 100644 --- a/examples/basic_voice.py +++ b/examples/basic_voice.py @@ -33,8 +33,6 @@ "source_address": "0.0.0.0", # bind to ipv4 since ipv6 addresses cause issues sometimes } -ffmpeg_options = {"options": "-vn"} - ytdl = youtube_dl.YoutubeDL(ytdl_format_options) @@ -59,7 +57,7 @@ async def from_url( filename = data["url"] if stream else ytdl.prepare_filename(data) - return cls(disnake.FFmpegPCMAudio(filename, **ffmpeg_options), data=data) + return cls(disnake.FFmpegPCMAudio(filename, options="-vn"), data=data) class Music(commands.Cog): diff --git a/examples/interactions/injections.py b/examples/interactions/injections.py index 27576d60bc..30c7554dd6 100644 --- a/examples/interactions/injections.py +++ b/examples/interactions/injections.py @@ -114,7 +114,7 @@ async def get_game_user( if user is None: return await db.get_game_user(id=inter.author.id) - game_user: GameUser = await db.search_game_user(username=user, server=server) + game_user: Optional[GameUser] = await db.search_game_user(username=user, server=server) if game_user is None: raise commands.CommandError(f"User with username {user!r} could not be found") diff --git a/examples/interactions/modal.py b/examples/interactions/modal.py index 00b2364789..f271c82f4c 100644 --- a/examples/interactions/modal.py +++ b/examples/interactions/modal.py @@ -2,6 +2,8 @@ """An example demonstrating two methods of sending modals and handling modal responses.""" +# pyright: reportUnknownLambdaType=false + import asyncio import os diff --git a/pyproject.toml b/pyproject.toml index 4756d55c4a..73f3ad1b9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,7 @@ codemod = [ ] typing = [ # this is not pyright itself, but the python wrapper - "pyright==1.1.291", + "pyright==1.1.336", "typing-extensions~=4.8.0", # only used for type-checking, version does not matter "pytz", diff --git a/test_bot/cogs/modals.py b/test_bot/cogs/modals.py index 13c84bddf2..c5d514a25c 100644 --- a/test_bot/cogs/modals.py +++ b/test_bot/cogs/modals.py @@ -65,7 +65,7 @@ async def create_tag_low(self, inter: disnake.AppCmdInter[commands.Bot]) -> None modal_inter: disnake.ModalInteraction = await self.bot.wait_for( "modal_submit", - check=lambda i: i.custom_id == "create_tag2" and i.author.id == inter.author.id, + check=lambda i: i.custom_id == "create_tag2" and i.author.id == inter.author.id, # type: ignore # unknown parameter type ) embed = disnake.Embed(title="Tag Creation") diff --git a/tests/ui/test_decorators.py b/tests/ui/test_decorators.py index 5fab1bb787..e9c3680873 100644 --- a/tests/ui/test_decorators.py +++ b/tests/ui/test_decorators.py @@ -30,16 +30,16 @@ def __init__(self, *, param: float = 42.0) -> None: class TestDecorator: def test_default(self) -> None: - with create_callback(ui.Button) as func: + with create_callback(ui.Button[ui.View]) as func: res = ui.button(custom_id="123")(func) - assert_type(res, ui.item.DecoratedItem[ui.Button]) + assert_type(res, ui.item.DecoratedItem[ui.Button[ui.View]]) assert func.__discord_ui_model_type__ is ui.Button assert func.__discord_ui_model_kwargs__ == {"custom_id": "123"} - with create_callback(ui.StringSelect) as func: + with create_callback(ui.StringSelect[ui.View]) as func: res = ui.string_select(custom_id="123")(func) - assert_type(res, ui.item.DecoratedItem[ui.StringSelect]) + assert_type(res, ui.item.DecoratedItem[ui.StringSelect[ui.View]]) assert func.__discord_ui_model_type__ is ui.StringSelect assert func.__discord_ui_model_kwargs__ == {"custom_id": "123"}