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 e49635f821..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: @@ -54,7 +54,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] experimental: [false] fail-fast: false continue-on-error: ${{ matrix.experimental }} @@ -72,7 +72,9 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: pdm install -d -Gspeed -Gdocs -Gvoice + run: | + pdm update --pre aiohttp # XXX: temporarily install aiohttp prerelease for 3.12 + pdm install -d -Gspeed -Gdocs -Gvoice - name: Add .venv/bin to PATH run: dirname "$(pdm info --python)" >> $GITHUB_PATH @@ -122,15 +124,14 @@ 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 - env: - LIBCST_PARSER_TYPE: "native" run: | nox -s codemod -- run-all if [ -n "$(git status --porcelain)" ]; then @@ -147,7 +148,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] os: ["windows-latest", "ubuntu-latest", "macos-latest"] experimental: [false] fail-fast: true @@ -167,11 +168,13 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: pdm install -dG test # needed for coverage + run: | + pdm update --pre aiohttp # XXX: temporarily install aiohttp prerelease for 3.12 + pdm install -dG test # needed for coverage - name: Test package install run: | - python -m pip install . + python -m pip install --pre . # XXX: temporarily install aiohttp prerelease for 3.12; remove --pre flag again later - name: Run pytest id: run_tests diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index db6ec422f0..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 @@ -56,6 +56,6 @@ repos: name: "run black in all files" - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.261 + rev: v0.0.292 hooks: - id: ruff 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/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/changelog/1091.feature.rst b/changelog/1091.feature.rst new file mode 100644 index 0000000000..6ae43e7e5f --- /dev/null +++ b/changelog/1091.feature.rst @@ -0,0 +1 @@ +Add :attr:`Permissions.create_guild_expressions` and :attr:`Permissions.create_events`. diff --git a/changelog/1094.doc.rst b/changelog/1094.doc.rst new file mode 100644 index 0000000000..13fe750a4b --- /dev/null +++ b/changelog/1094.doc.rst @@ -0,0 +1 @@ +Add inherited attributes to :class:`TeamMember`, and fix :attr:`TeamMember.avatar` documentation. diff --git a/changelog/1094.feature.0.rst b/changelog/1094.feature.0.rst new file mode 100644 index 0000000000..dcb2dcf367 --- /dev/null +++ b/changelog/1094.feature.0.rst @@ -0,0 +1 @@ +Add :attr:`TeamMember.role`. diff --git a/changelog/1094.feature.1.rst b/changelog/1094.feature.1.rst new file mode 100644 index 0000000000..1b7eb2cd33 --- /dev/null +++ b/changelog/1094.feature.1.rst @@ -0,0 +1 @@ +|commands| Update :meth:`Bot.is_owner ` to take team member roles into account. 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/changelog/1112.doc.rst b/changelog/1112.doc.rst new file mode 100644 index 0000000000..e510f78951 --- /dev/null +++ b/changelog/1112.doc.rst @@ -0,0 +1 @@ +Document the :class:`.Option` attributes, the ``description`` and ``options`` properties for :class:`.ext.commands.InvokableSlashCommand` and the ``description`` and ``body`` properties for :class:`.ext.commands.SubCommand`. diff --git a/changelog/1116.misc.rst b/changelog/1116.misc.rst new file mode 100644 index 0000000000..7e17a486ef --- /dev/null +++ b/changelog/1116.misc.rst @@ -0,0 +1 @@ +|commands| Rewrite slash command signature evaluation to use the same mechanism as prefix command signatures. This should not have an impact on user code, but streamlines future changes. diff --git a/changelog/1117.misc.rst b/changelog/1117.misc.rst new file mode 100644 index 0000000000..3a10d0f07e --- /dev/null +++ b/changelog/1117.misc.rst @@ -0,0 +1 @@ +Start testing with Python 3.12 in CI. diff --git a/changelog/1120.bugfix.rst b/changelog/1120.bugfix.rst new file mode 100644 index 0000000000..146ac4a8de --- /dev/null +++ b/changelog/1120.bugfix.rst @@ -0,0 +1 @@ +|commands| Fix edge case in evaluation of multiple identical annotations with forwardrefs in a single signature. diff --git a/changelog/1121.feature.rst b/changelog/1121.feature.rst new file mode 100644 index 0000000000..1294ba4044 --- /dev/null +++ b/changelog/1121.feature.rst @@ -0,0 +1 @@ +Make :class:`Interaction` and subtypes accept the bot type as a generic parameter to denote the type returned by the :attr:`~Interaction.bot` and :attr:`~Interaction.client` properties. diff --git a/changelog/1123.bugfix.rst b/changelog/1123.bugfix.rst new file mode 100644 index 0000000000..625f275381 --- /dev/null +++ b/changelog/1123.bugfix.rst @@ -0,0 +1 @@ +Fix :meth:`Thread.permissions_for` not working in some cases due to an incorrect import. diff --git a/changelog/1126.doc.rst b/changelog/1126.doc.rst new file mode 100644 index 0000000000..44fa13bf31 --- /dev/null +++ b/changelog/1126.doc.rst @@ -0,0 +1 @@ +Make all "Supported Operations" container elements collapsible. diff --git a/changelog/1128.feature.rst b/changelog/1128.feature.rst new file mode 100644 index 0000000000..66c35b1935 --- /dev/null +++ b/changelog/1128.feature.rst @@ -0,0 +1 @@ +|commands| Support Python 3.12's ``type`` statement and :class:`py:typing.TypeAliasType` annotations in command signatures. diff --git a/changelog/1133.bugfix.rst b/changelog/1133.bugfix.rst new file mode 100644 index 0000000000..fdf45729eb --- /dev/null +++ b/changelog/1133.bugfix.rst @@ -0,0 +1 @@ +|commands| Fix erroneous :class:`LocalizationWarning`\s when using localized slash command parameters in cogs. diff --git a/changelog/1134.misc.rst b/changelog/1134.misc.rst new file mode 100644 index 0000000000..664f4a332d --- /dev/null +++ b/changelog/1134.misc.rst @@ -0,0 +1 @@ +Add :class:`StandardSticker` to ``stickers`` parameter type annotation of :meth:`Messageable.send` and :meth:`ForumChannel.create_thread`. diff --git a/changelog/1136.bugfix.rst b/changelog/1136.bugfix.rst new file mode 100644 index 0000000000..571cba6cbd --- /dev/null +++ b/changelog/1136.bugfix.rst @@ -0,0 +1 @@ +Update ``choices`` type in app commands to accept any :class:`~py:typing.Sequence` or :class:`~py:typing.Mapping`, instead of the more constrained :class:`list`/:class:`dict` types. 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/__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/disnake/abc.py b/disnake/abc.py index 605bb725aa..fda31ec5fb 100644 --- a/disnake/abc.py +++ b/disnake/abc.py @@ -42,7 +42,7 @@ from .partial_emoji import PartialEmoji from .permissions import PermissionOverwrite, Permissions from .role import Role -from .sticker import GuildSticker, StickerItem +from .sticker import GuildSticker, StandardSticker, StickerItem from .ui.action_row import components_to_dict from .utils import _overload_with_permissions from .voice_client import VoiceClient, VoiceProtocol @@ -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): @@ -853,7 +853,9 @@ async def set_permissions( ban_members: Optional[bool] = ..., change_nickname: Optional[bool] = ..., connect: Optional[bool] = ..., + create_events: Optional[bool] = ..., create_forum_threads: Optional[bool] = ..., + create_guild_expressions: Optional[bool] = ..., create_instant_invite: Optional[bool] = ..., create_private_threads: Optional[bool] = ..., create_public_threads: Optional[bool] = ..., @@ -1427,7 +1429,7 @@ async def send( tts: bool = ..., embed: Embed = ..., file: File = ..., - stickers: Sequence[Union[GuildSticker, StickerItem]] = ..., + stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = ..., delete_after: float = ..., nonce: Union[str, int] = ..., suppress_embeds: bool = ..., @@ -1448,7 +1450,7 @@ async def send( tts: bool = ..., embed: Embed = ..., files: List[File] = ..., - stickers: Sequence[Union[GuildSticker, StickerItem]] = ..., + stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = ..., delete_after: float = ..., nonce: Union[str, int] = ..., suppress_embeds: bool = ..., @@ -1469,7 +1471,7 @@ async def send( tts: bool = ..., embeds: List[Embed] = ..., file: File = ..., - stickers: Sequence[Union[GuildSticker, StickerItem]] = ..., + stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = ..., delete_after: float = ..., nonce: Union[str, int] = ..., suppress_embeds: bool = ..., @@ -1490,7 +1492,7 @@ async def send( tts: bool = ..., embeds: List[Embed] = ..., files: List[File] = ..., - stickers: Sequence[Union[GuildSticker, StickerItem]] = ..., + stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = ..., delete_after: float = ..., nonce: Union[str, int] = ..., suppress_embeds: bool = ..., @@ -1512,7 +1514,7 @@ async def send( embeds: Optional[List[Embed]] = None, file: Optional[File] = None, files: Optional[List[File]] = None, - stickers: Optional[Sequence[Union[GuildSticker, StickerItem]]] = None, + stickers: Optional[Sequence[Union[GuildSticker, StandardSticker, StickerItem]]] = None, delete_after: Optional[float] = None, nonce: Optional[Union[str, int]] = None, suppress_embeds: Optional[bool] = None, @@ -1565,7 +1567,7 @@ async def send( files: List[:class:`.File`] A list of files to upload. Must be a maximum of 10. This cannot be mixed with the ``file`` parameter. - stickers: Sequence[Union[:class:`.GuildSticker`, :class:`.StickerItem`]] + stickers: Sequence[Union[:class:`.GuildSticker`, :class:`.StandardSticker`, :class:`.StickerItem`]] A list of stickers to upload. Must be a maximum of 3. .. versionadded:: 2.0 diff --git a/disnake/activity.py b/disnake/activity.py index a213bf5a75..3c290edd17 100644 --- a/disnake/activity.py +++ b/disnake/activity.py @@ -404,7 +404,7 @@ class Game(BaseActivity): This is typically displayed via **Playing** on the official Discord client. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -487,7 +487,7 @@ class Streaming(BaseActivity): This is typically displayed via **Streaming** on the official Discord client. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -597,7 +597,7 @@ def __hash__(self) -> int: class Spotify(_BaseActivity): """Represents a Spotify listening activity from Discord. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -770,7 +770,7 @@ def party_id(self) -> str: class CustomActivity(BaseActivity): """Represents a Custom activity from Discord. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -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/app_commands.py b/disnake/app_commands.py index 17c3fd713a..727f35cb93 100644 --- a/disnake/app_commands.py +++ b/disnake/app_commands.py @@ -5,7 +5,7 @@ import math import re from abc import ABC -from typing import TYPE_CHECKING, ClassVar, Dict, List, Mapping, Optional, Tuple, Union +from typing import TYPE_CHECKING, ClassVar, List, Mapping, Optional, Sequence, Tuple, Union from .enums import ( ApplicationCommandPermissionType, @@ -37,10 +37,10 @@ ) Choices = Union[ - List["OptionChoice"], - List[ApplicationCommandOptionChoiceValue], - Dict[str, ApplicationCommandOptionChoiceValue], - List[Localized[str]], + Sequence["OptionChoice"], + Sequence[ApplicationCommandOptionChoiceValue], + Mapping[str, ApplicationCommandOptionChoiceValue], + Sequence[Localized[str]], ] APIApplicationCommand = Union["APIUserCommand", "APIMessageCommand", "APISlashCommand"] @@ -179,8 +179,42 @@ class Option: The option type, e.g. :class:`OptionType.user`. required: :class:`bool` Whether this option is required. - choices: Union[List[:class:`OptionChoice`], List[Union[:class:`str`, :class:`int`]], Dict[:class:`str`, Union[:class:`str`, :class:`int`]]] - The list of option choices. + choices: Union[Sequence[:class:`OptionChoice`], Sequence[Union[:class:`str`, :class:`int`, :class:`float`]], Mapping[:class:`str`, Union[:class:`str`, :class:`int`, :class:`float`]]] + The pre-defined choices for this option. + options: List[:class:`Option`] + The list of sub options. Normally you don't have to specify it directly, + instead consider using ``@main_cmd.sub_command`` or ``@main_cmd.sub_command_group`` decorators. + channel_types: List[:class:`ChannelType`] + The list of channel types that your option supports, if the type is :class:`OptionType.channel`. + By default, it supports all channel types. + autocomplete: :class:`bool` + Whether this option can be autocompleted. + min_value: Union[:class:`int`, :class:`float`] + The minimum value permitted. + max_value: Union[:class:`int`, :class:`float`] + The maximum value permitted. + min_length: :class:`int` + The minimum length for this option if this is a string option. + + .. versionadded:: 2.6 + + max_length: :class:`int` + The maximum length for this option if this is a string option. + + .. versionadded:: 2.6 + + Attributes + ---------- + name: :class:`str` + The option's name. + description: :class:`str` + The option's description. + type: :class:`OptionType` + The option type, e.g. :class:`OptionType.user`. + required: :class:`bool` + Whether this option is required. + choices: List[:class:`OptionChoice`] + The list of pre-defined choices. options: List[:class:`Option`] The list of sub options. Normally you don't have to specify it directly, instead consider using ``@main_cmd.sub_command`` or ``@main_cmd.sub_command_group`` decorators. @@ -270,6 +304,9 @@ def __init__( if autocomplete: raise TypeError("can not specify both choices and autocomplete args") + if isinstance(choices, str): # str matches `Sequence[str]`, but isn't meant to be used + raise TypeError("choices argument should be a list/sequence or dict, not str") + if isinstance(choices, Mapping): self.choices = [OptionChoice(name, value) for name, value in choices.items()] else: @@ -336,7 +373,7 @@ def from_dict(cls, data: ApplicationCommandOptionPayload) -> Option: def add_choice( self, name: LocalizedRequired, - value: Union[str, int], + value: ApplicationCommandOptionChoiceValue, ) -> None: """Adds an OptionChoice to the list of current choices, parameters are the same as for :class:`OptionChoice`. @@ -354,7 +391,7 @@ def add_option( description: LocalizedOptional = None, type: Optional[OptionType] = None, required: bool = False, - choices: Optional[List[OptionChoice]] = None, + choices: Optional[Choices] = None, options: Optional[list] = None, channel_types: Optional[List[ChannelType]] = None, autocomplete: bool = False, @@ -850,7 +887,7 @@ def add_option( description: LocalizedOptional = None, type: Optional[OptionType] = None, required: bool = False, - choices: Optional[List[OptionChoice]] = None, + choices: Optional[Choices] = None, options: Optional[list] = None, channel_types: Optional[List[ChannelType]] = None, autocomplete: bool = False, diff --git a/disnake/asset.py b/disnake/asset.py index fc8fa6c7ea..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"} @@ -164,7 +164,7 @@ async def to_file( class Asset(AssetMixin): """Represents a CDN asset on Discord. - .. container:: operations + .. collapse:: operations .. describe:: str(x) diff --git a/disnake/audit_logs.py b/disnake/audit_logs.py index 9d45912cd9..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 @@ -517,7 +517,7 @@ class AuditLogEntry(Hashable): You can retrieve these via :meth:`Guild.audit_logs`, or via the :func:`on_audit_log_entry_create` event. - .. container:: operations + .. collapse:: operations .. describe:: x == y diff --git a/disnake/channel.py b/disnake/channel.py index 263735e24e..042611b563 100644 --- a/disnake/channel.py +++ b/disnake/channel.py @@ -74,7 +74,7 @@ from .message import AllowedMentions, Message, PartialMessage from .role import Role from .state import ConnectionState - from .sticker import GuildSticker, StickerItem + from .sticker import GuildSticker, StandardSticker, StickerItem from .threads import AnyThreadArchiveDuration, ThreadType from .types.channel import ( CategoryChannel as CategoryChannelPayload, @@ -103,7 +103,7 @@ async def _single_delete_strategy(messages: Iterable[Message]) -> None: class TextChannel(disnake.abc.Messageable, disnake.abc.GuildChannel, Hashable): """Represents a Discord guild text channel. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -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 @@ -1207,6 +1207,7 @@ def permissions_for( denied.update( manage_channels=True, manage_roles=True, + create_events=True, manage_events=True, manage_webhooks=True, ) @@ -1217,7 +1218,7 @@ def permissions_for( class VoiceChannel(disnake.abc.Messageable, VocalGuildChannel): """Represents a Discord guild voice channel. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -1628,7 +1629,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 @@ -1871,7 +1872,7 @@ class StageChannel(disnake.abc.Messageable, VocalGuildChannel): .. versionadded:: 1.7 - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -2453,7 +2454,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 @@ -2696,7 +2697,7 @@ class CategoryChannel(disnake.abc.GuildChannel, Hashable): These are useful to group channels to logical compartments. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -2946,7 +2947,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 @@ -3145,7 +3146,7 @@ class ForumChannel(disnake.abc.GuildChannel, Hashable): .. versionadded:: 2.5 - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -3619,7 +3620,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 @@ -3790,7 +3791,7 @@ async def create_thread( file: File = ..., suppress_embeds: bool = ..., flags: MessageFlags = ..., - stickers: Sequence[Union[GuildSticker, StickerItem]] = ..., + stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = ..., allowed_mentions: AllowedMentions = ..., view: View = ..., components: Components = ..., @@ -3811,7 +3812,7 @@ async def create_thread( files: List[File] = ..., suppress_embeds: bool = ..., flags: MessageFlags = ..., - stickers: Sequence[Union[GuildSticker, StickerItem]] = ..., + stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = ..., allowed_mentions: AllowedMentions = ..., view: View = ..., components: Components = ..., @@ -3832,7 +3833,7 @@ async def create_thread( file: File = ..., suppress_embeds: bool = ..., flags: MessageFlags = ..., - stickers: Sequence[Union[GuildSticker, StickerItem]] = ..., + stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = ..., allowed_mentions: AllowedMentions = ..., view: View = ..., components: Components = ..., @@ -3853,7 +3854,7 @@ async def create_thread( files: List[File] = ..., suppress_embeds: bool = ..., flags: MessageFlags = ..., - stickers: Sequence[Union[GuildSticker, StickerItem]] = ..., + stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = ..., allowed_mentions: AllowedMentions = ..., view: View = ..., components: Components = ..., @@ -3875,7 +3876,7 @@ async def create_thread( files: List[File] = MISSING, suppress_embeds: bool = MISSING, flags: MessageFlags = MISSING, - stickers: Sequence[Union[GuildSticker, StickerItem]] = MISSING, + stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = MISSING, allowed_mentions: AllowedMentions = MISSING, view: View = MISSING, components: Components[MessageUIComponent] = MISSING, @@ -3939,7 +3940,7 @@ async def create_thread( files: List[:class:`.File`] A list of files to upload. Must be a maximum of 10. This cannot be mixed with the ``file`` parameter. - stickers: Sequence[Union[:class:`.GuildSticker`, :class:`.StickerItem`]] + stickers: Sequence[Union[:class:`.GuildSticker`, :class:`.StandardSticker`, :class:`.StickerItem`]] A list of stickers to upload. Must be a maximum of 3. allowed_mentions: :class:`.AllowedMentions` Controls the mentions being processed in this message. If this is @@ -3994,7 +3995,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) ) @@ -4184,7 +4185,7 @@ def get_tag_by_name(self, name: str, /) -> Optional[ForumTag]: class DMChannel(disnake.abc.Messageable, Hashable): """Represents a Discord direct message channel. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -4347,7 +4348,7 @@ def get_partial_message(self, message_id: int, /) -> PartialMessage: class GroupChannel(disnake.abc.Messageable, Hashable): """Represents a Discord group channel. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -4506,7 +4507,7 @@ class PartialMessageable(disnake.abc.Messageable, Hashable): .. versionadded:: 2.0 - .. container:: operations + .. collapse:: operations .. describe:: x == y 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/colour.py b/disnake/colour.py index 82e8ef1bb3..4bd6585ea2 100644 --- a/disnake/colour.py +++ b/disnake/colour.py @@ -22,7 +22,7 @@ class Colour: There is an alias for this called Color. - .. container:: operations + .. collapse:: operations .. describe:: x == y 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/embeds.py b/disnake/embeds.py index 22ae7398af..1866d8d7eb 100644 --- a/disnake/embeds.py +++ b/disnake/embeds.py @@ -112,7 +112,7 @@ class _EmbedAuthorProxy(Sized, Protocol): class Embed: """Represents a Discord embed. - .. container:: operations + .. collapse:: operations .. describe:: x == y diff --git a/disnake/emoji.py b/disnake/emoji.py index fb5ee1c3b4..badedbce86 100644 --- a/disnake/emoji.py +++ b/disnake/emoji.py @@ -28,7 +28,7 @@ class Emoji(_EmojiTag, AssetMixin): Depending on the way this object was created, some of the attributes can have a value of ``None``. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -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)] @@ -192,7 +192,7 @@ async def delete(self, *, reason: Optional[str] = None) -> None: Raises ------ Forbidden - You are not allowed to delete emojis. + You are not allowed to delete this emoji. HTTPException An error occurred deleting the emoji. """ @@ -227,7 +227,7 @@ async def edit( Raises ------ Forbidden - You are not allowed to edit emojis. + You are not allowed to edit this emoji. HTTPException An error occurred editing the emoji. diff --git a/disnake/enums.py b/disnake/enums.py index 29835a8715..912cb36183 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -35,6 +35,7 @@ "ActivityType", "NotificationLevel", "TeamMembershipState", + "TeamMemberRole", "WebhookType", "ExpireBehaviour", "ExpireBehavior", @@ -466,7 +467,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" @@ -551,6 +552,15 @@ class TeamMembershipState(Enum): accepted = 2 +class TeamMemberRole(Enum): + admin = "admin" + developer = "developer" + read_only = "read_only" + + def __str__(self) -> str: + return self.name + + class WebhookType(Enum): incoming = 1 channel_follower = 2 @@ -627,7 +637,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 @@ -753,7 +763,7 @@ def __str__(self) -> str: # reference: https://discord.com/developers/docs/reference#locales class Locale(Enum): bg = "bg" - "Bulgarian | български" # noqa: RUF001 + "Bulgarian | български" cs = "cs" "Czech | Čeština" da = "da" @@ -761,7 +771,7 @@ class Locale(Enum): de = "de" "German | Deutsch" el = "el" - "Greek | Ελληνικά" # noqa: RUF001 + "Greek | Ελληνικά" en_GB = "en-GB" "English, UK | English, UK" en_US = "en-US" @@ -807,7 +817,7 @@ class Locale(Enum): tr = "tr" "Turkish | Türkçe" uk = "uk" - "Ukrainian | Українська" # noqa: RUF001 + "Ukrainian | Українська" vi = "vi" "Vietnamese | Tiếng Việt" zh_CN = "zh-CN" diff --git a/disnake/errors.py b/disnake/errors.py index 21a1834dff..416a32d7f1 100644 --- a/disnake/errors.py +++ b/disnake/errors.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Mapping, Optional, Tuple, Union if TYPE_CHECKING: from aiohttp import ClientResponse, ClientWebSocketResponse @@ -225,7 +225,7 @@ class ConnectionClosed(ClientException): """ # https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-close-event-codes - GATEWAY_CLOSE_EVENT_REASONS: Dict[int, str] = { + GATEWAY_CLOSE_EVENT_REASONS: ClassVar[Mapping[int, str]] = { 4000: "Unknown error", 4001: "Unknown opcode", 4002: "Decode error", @@ -243,7 +243,7 @@ class ConnectionClosed(ClientException): } # https://discord.com/developers/docs/topics/opcodes-and-status-codes#voice-voice-close-event-codes - GATEWAY_VOICE_CLOSE_EVENT_REASONS: Dict[int, str] = { + GATEWAY_VOICE_CLOSE_EVENT_REASONS: ClassVar[Mapping[int, str]] = { **GATEWAY_CLOSE_EVENT_REASONS, 4002: "Failed to decode payload", 4006: "Session no longer valid", 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..b5e0498399 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 @@ -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 @@ -636,7 +636,9 @@ def default_member_permissions( ban_members: bool = ..., change_nickname: bool = ..., connect: bool = ..., + create_events: bool = ..., create_forum_threads: bool = ..., + create_guild_expressions: bool = ..., create_instant_invite: bool = ..., create_private_threads: bool = ..., create_public_threads: bool = ..., diff --git a/disnake/ext/commands/bot.py b/disnake/ext/commands/bot.py index 5c3ba59eac..825f96e6ae 100644 --- a/disnake/ext/commands/bot.py +++ b/disnake/ext/commands/bot.py @@ -184,7 +184,7 @@ class Bot(BotBase, InteractionBotBase, disnake.Client): owner_ids: Optional[Collection[:class:`int`]] The IDs of the users that own the bot. This is similar to :attr:`owner_id`. If this is not set and the application is team based, then it is - fetched automatically using :meth:`~.Bot.application_info`. + fetched automatically using :meth:`~.Bot.application_info` (taking team roles into account). For performance reasons it is recommended to use a :class:`set` for the collection. You cannot set both ``owner_id`` and ``owner_ids``. @@ -403,7 +403,7 @@ class InteractionBot(InteractionBotBase, disnake.Client): owner_ids: Optional[Collection[:class:`int`]] The IDs of the users that own the bot. This is similar to :attr:`owner_id`. If this is not set and the application is team based, then it is - fetched automatically using :meth:`~.Bot.application_info`. + fetched automatically using :meth:`~.Bot.application_info` (taking team roles into account). For performance reasons it is recommended to use a :class:`set` for the collection. You cannot set both ``owner_id`` and ``owner_ids``. 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/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/common_bot_base.py b/disnake/ext/commands/common_bot_base.py index 841c3df837..737736c170 100644 --- a/disnake/ext/commands/common_bot_base.py +++ b/disnake/ext/commands/common_bot_base.py @@ -4,6 +4,7 @@ import asyncio import collections.abc +import importlib.machinery import importlib.util import logging import os @@ -19,8 +20,6 @@ from .cog import Cog if TYPE_CHECKING: - import importlib.machinery - from ._types import CoroFunc from .bot import AutoShardedBot, AutoShardedInteractionBot, Bot, InteractionBot from .help import HelpCommand @@ -82,8 +81,13 @@ async def _fill_owners(self) -> None: app: disnake.AppInfo = await self.application_info() # type: ignore if app.team: - self.owners = set(app.team.members) - self.owner_ids = {m.id for m in app.team.members} + self.owners = owners = { + member + for member in app.team.members + # these roles can access the bot token, consider them bot owners + if member.role in (disnake.TeamMemberRole.admin, disnake.TeamMemberRole.developer) + } + self.owner_ids = {m.id for m in owners} else: self.owner = app.owner self.owner_id = app.owner.id @@ -131,6 +135,10 @@ async def is_owner(self, user: Union[disnake.User, disnake.Member]) -> bool: The function also checks if the application is team-owned if :attr:`owner_ids` is not set. + .. versionchanged:: 2.10 + Also takes team roles into account; only team members with the :attr:`~disnake.TeamMemberRole.admin` + or :attr:`~disnake.TeamMemberRole.developer` roles are considered bot owners. + Parameters ---------- user: :class:`.abc.User` diff --git a/disnake/ext/commands/context.py b/disnake/ext/commands/context.py index 5c25bdc9dc..0876e7d3a4 100644 --- a/disnake/ext/commands/context.py +++ b/disnake/ext/commands/context.py @@ -290,9 +290,7 @@ def voice_client(self) -> Optional[VoiceProtocol]: return g.voice_client if g else None async def send_help(self, *args: Any) -> Any: - """send_help(entity=) - - |coro| + """|coro| Shows the help command for the specified entity if given. The entity can be a command or a cog. 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 2d7ff5497e..b9d49ab269 100644 --- a/disnake/ext/commands/core.py +++ b/disnake/ext/commands/core.py @@ -27,7 +27,12 @@ ) import disnake -from disnake.utils import _generated, _overload_with_permissions +from disnake.utils import ( + _generated, + _overload_with_permissions, + get_signature_parameters, + unwrap_function, +) from ._types import _BaseCommand from .cog import Cog @@ -62,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__ = ( @@ -76,6 +81,8 @@ "has_any_role", "check", "check_any", + "app_check", + "app_check_any", "before_invoke", "after_invoke", "bot_has_role", @@ -114,42 +121,6 @@ P = TypeVar("P") -def unwrap_function(function: Callable[..., Any]) -> Callable[..., Any]: - partial = functools.partial - while True: - if hasattr(function, "__wrapped__"): - function = function.__wrapped__ - elif isinstance(function, partial): - function = function.func - else: - return function - - -def get_signature_parameters( - function: Callable[..., Any], globalns: Dict[str, Any] -) -> Dict[str, inspect.Parameter]: - signature = inspect.signature(function) - params = {} - cache: Dict[str, Any] = {} - eval_annotation = disnake.utils.evaluate_annotation - for name, parameter in signature.parameters.items(): - annotation = parameter.annotation - if annotation is parameter.empty: - params[name] = parameter - continue - if annotation is None: - params[name] = parameter.replace(annotation=type(None)) - continue - - annotation = eval_annotation(annotation, globalns, globalns, cache) - if annotation is Greedy: - raise TypeError("Unparameterized Greedy[...] is disallowed in signature.") - - params[name] = parameter.replace(annotation=annotation) - - return params - - def wrap_callback(coro): @functools.wraps(coro) async def wrapped(*args, **kwargs): @@ -410,7 +381,11 @@ def callback(self, function: CommandCallback[CogT, Any, P, T]) -> None: except AttributeError: globalns = {} - self.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.") + self.params = params def add_check(self, func: Check) -> None: """Adds a check to the command. @@ -632,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: @@ -718,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) @@ -814,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 @@ -1722,6 +1663,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. @@ -1774,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 @@ -1794,6 +1738,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 @@ -1850,6 +1797,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. @@ -2012,7 +1999,9 @@ def has_permissions( ban_members: bool = ..., change_nickname: bool = ..., connect: bool = ..., + create_events: bool = ..., create_forum_threads: bool = ..., + create_guild_expressions: bool = ..., create_instant_invite: bool = ..., create_private_threads: bool = ..., create_public_threads: bool = ..., @@ -2134,7 +2123,9 @@ def bot_has_permissions( ban_members: bool = ..., change_nickname: bool = ..., connect: bool = ..., + create_events: bool = ..., create_forum_threads: bool = ..., + create_guild_expressions: bool = ..., create_instant_invite: bool = ..., create_private_threads: bool = ..., create_public_threads: bool = ..., @@ -2234,7 +2225,9 @@ def has_guild_permissions( ban_members: bool = ..., change_nickname: bool = ..., connect: bool = ..., + create_events: bool = ..., create_forum_threads: bool = ..., + create_guild_expressions: bool = ..., create_instant_invite: bool = ..., create_private_threads: bool = ..., create_public_threads: bool = ..., @@ -2331,7 +2324,9 @@ def bot_has_guild_permissions( ban_members: bool = ..., change_nickname: bool = ..., connect: bool = ..., + create_events: bool = ..., create_forum_threads: bool = ..., + create_guild_expressions: bool = ..., create_instant_invite: bool = ..., create_private_threads: bool = ..., create_public_threads: bool = ..., 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/flag_converter.py b/disnake/ext/commands/flag_converter.py index 39a4b54808..37c97936c0 100644 --- a/disnake/ext/commands/flag_converter.py +++ b/disnake/ext/commands/flag_converter.py @@ -435,7 +435,7 @@ class FlagConverter(metaclass=FlagsMeta): how this converter works, check the appropriate :ref:`documentation `. - .. container:: operations + .. collapse:: operations .. describe:: iter(x) diff --git a/disnake/ext/commands/flags.py b/disnake/ext/commands/flags.py index ade3e79182..866566af3b 100644 --- a/disnake/ext/commands/flags.py +++ b/disnake/ext/commands/flags.py @@ -25,7 +25,7 @@ class CommandSyncFlags(BaseFlags): .. versionadded:: 2.7 - .. container:: operations + .. collapse:: operations .. describe:: x == y diff --git a/disnake/ext/commands/help.py b/disnake/ext/commands/help.py index 54bcb1b43a..ecd3988b86 100644 --- a/disnake/ext/commands/help.py +++ b/disnake/ext/commands/help.py @@ -5,7 +5,7 @@ import functools import itertools import re -from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterable, Mapping, Optional import disnake.utils @@ -48,7 +48,7 @@ class Paginator: """A class that aids in paginating code blocks for Discord messages. - .. container:: operations + .. collapse:: operations .. describe:: len(x) @@ -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 @@ -279,7 +269,7 @@ class HelpCommand: ones passed in the :class:`.Command` constructor. """ - MENTION_TRANSFORMS = { + MENTION_TRANSFORMS: ClassVar[Mapping[str, str]] = { "@everyone": "@\u200beveryone", "@here": "@\u200bhere", r"<@!?[0-9]{17,19}>": "@deleted-user", @@ -378,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/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/disnake/ext/commands/params.py b/disnake/ext/commands/params.py index 2ab93359d2..e472c1ae13 100644 --- a/disnake/ext/commands/params.py +++ b/disnake/ext/commands/params.py @@ -6,10 +6,12 @@ import asyncio import collections.abc +import copy import inspect import itertools import math import sys +import types from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum, EnumMeta @@ -31,9 +33,7 @@ Type, TypeVar, Union, - get_args, get_origin, - get_type_hints, ) import disnake @@ -43,7 +43,12 @@ from disnake.ext import commands from disnake.i18n import Localized from disnake.interactions import ApplicationCommandInteraction -from disnake.utils import 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 @@ -81,9 +86,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) @@ -110,17 +112,26 @@ def issubclass_(obj: Any, tp: Union[TypeT, Tuple[TypeT, ...]]) -> TypeGuard[TypeT]: + """Similar to the builtin `issubclass`, but more lenient. + Can also handle unions (`issubclass(Union[int, str], int)`) and + generic types (`issubclass(X[T], X)`) in the first argument. + """ if not isinstance(tp, (type, tuple)): return False - elif not isinstance(obj, type): - # Assume we have a type hint - if get_origin(obj) in (Union, UnionType, Optional): - obj = get_args(obj) - return any(isinstance(o, type) and issubclass(o, tp) for o in obj) - else: - # Other type hint specializations are not supported - return False - return issubclass(obj, tp) + elif isinstance(obj, type): + # common case + return issubclass(obj, tp) + + # At this point, `obj` is likely a generic type hint + if (origin := get_origin(obj)) is None: + return False + + if origin in (Union, UnionType): + # If we have a Union, try matching any of its args + # (recursively, to handle possibly generic types inside this union) + return any(issubclass_(o, tp) for o in obj.__args__) + else: + return isinstance(origin, type) and issubclass(origin, tp) def remove_optionals(annotation: Any) -> Any: @@ -135,37 +146,6 @@ def remove_optionals(annotation: Any) -> Any: return annotation -def signature(func: Callable) -> inspect.Signature: - """Get the signature with evaluated annotations wherever possible - - This is equivalent to `signature(..., eval_str=True)` in python 3.10 - """ - if sys.version_info >= (3, 10): - return inspect.signature(func, eval_str=True) - - if inspect.isfunction(func) or inspect.ismethod(func): - typehints = get_type_hints(func) - else: - typehints = get_type_hints(func.__call__) - - signature = inspect.signature(func) - parameters = [] - - for name, param in signature.parameters.items(): - if isinstance(param.annotation, str): - param = param.replace(annotation=typehints.get(name, inspect.Parameter.empty)) - if param.annotation is type(None): - param = param.replace(annotation=None) - - parameters.append(param) - - return_annotation = typehints.get("return", inspect.Parameter.empty) - if return_annotation is type(None): - return_annotation = None - - return signature.replace(parameters=parameters, return_annotation=return_annotation) - - def _xt_to_xe(xe: Optional[float], xt: Optional[float], direction: float = 1) -> Optional[float]: """Function for combining xt and xe @@ -472,8 +452,8 @@ class ParamInfo: .. versionchanged:: 2.5 Added support for localizations. - choices: Union[List[:class:`.OptionChoice`], List[Union[:class:`str`, :class:`int`]], Dict[:class:`str`, Union[:class:`str`, :class:`int`]]] - The list of choices of this slash command option. + choices: Union[Sequence[:class:`.OptionChoice`], Sequence[Union[:class:`str`, :class:`int`, :class:`float`]], Mapping[:class:`str`, Union[:class:`str`, :class:`int`, :class:`float`]]] + The pre-defined choices for this option. ge: :class:`float` The lowest allowed value for this option. le: :class:`float` @@ -561,7 +541,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) @@ -575,7 +555,7 @@ def copy(self) -> ParamInfo: ins.converter = self.converter ins.convert_default = self.convert_default ins.autocomplete = self.autocomplete - ins.choices = self.choices.copy() + ins.choices = copy.copy(self.choices) ins.type = self.type ins.channel_types = self.channel_types.copy() ins.max_value = self.max_value @@ -787,7 +767,14 @@ def parse_annotation(self, annotation: Any, converter_mode: bool = False) -> boo return True def parse_converter_annotation(self, converter: Callable, fallback_annotation: Any) -> None: - _, parameters = isolate_self(signature(converter)) + if isinstance(converter, (types.FunctionType, types.MethodType)): + converter_func = converter + else: + # if converter isn't a function/method, assume it's a callable object/type + # (we need `__call__` here to get the correct global namespace later, since + # classes do not have `__globals__`) + converter_func = converter.__call__ + _, parameters = isolate_self(converter_func) if len(parameters) != 1: raise TypeError( @@ -850,9 +837,9 @@ def to_option(self) -> Option: def safe_call(function: Callable[..., T], /, *possible_args: Any, **possible_kwargs: Any) -> T: """Calls a function without providing any extra unexpected arguments""" MISSING: Any = object() - sig = signature(function) + parameters = get_signature_parameters(function) - kinds = {p.kind for p in sig.parameters.values()} + kinds = {p.kind for p in parameters.values()} arb = {inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD} if arb.issubset(kinds): raise TypeError( @@ -866,7 +853,7 @@ def safe_call(function: Callable[..., T], /, *possible_args: Any, **possible_kwa for index, parameter, posarg in itertools.zip_longest( itertools.count(), - sig.parameters.values(), + parameters.values(), possible_args, fillvalue=MISSING, ): @@ -895,24 +882,30 @@ def safe_call(function: Callable[..., T], /, *possible_args: Any, **possible_kwa def isolate_self( - sig: inspect.Signature, + 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""" - parameters = dict(sig.parameters) - parametersl = list(sig.parameters.values()) + """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), {} + parameters = dict(parameters) # shallow copy + parametersl = list(parameters.values()) + 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: annot = parametersl[0].annotation - annot = get_origin(annot) or annot if issubclass_(annot, ApplicationCommandInteraction) or annot is inspect.Parameter.empty: inter_param = parameters.pop(parametersl[0].name) @@ -954,19 +947,15 @@ def classify_autocompleter(autocompleter: AnyAutocompleter) -> None: def collect_params( function: Callable, - sig: Optional[inspect.Signature] = None, + parameters: Optional[Dict[str, inspect.Parameter]] = None, ) -> Tuple[Optional[str], Optional[str], List[ParamInfo], Dict[str, Injection]]: """Collect all parameters in a function. - Optionally accepts an `inspect.Signature` object (as an optimization), - calls `signature(function)` if not provided. + Optionally accepts a `{str: inspect.Parameter}` dict as an optimization. Returns: (`cog parameter`, `interaction parameter`, `param infos`, `injections`) """ - if sig is None: - sig = signature(function) - - (cog_param, inter_param), parameters = isolate_self(sig) + (cog_param, inter_param), parameters = isolate_self(function, parameters) doc = disnake.utils.parse_docstring(function)["params"] @@ -984,9 +973,7 @@ def collect_params( injections[parameter.name] = default elif parameter.annotation in Injection._registered: injections[parameter.name] = Injection._registered[parameter.annotation] - elif issubclass_( - get_origin(parameter.annotation) or parameter.annotation, ApplicationCommandInteraction - ): + elif issubclass_(parameter.annotation, ApplicationCommandInteraction): if inter_param is None: inter_param = parameter else: @@ -1092,10 +1079,10 @@ def expand_params(command: AnySlashCommand) -> List[Option]: Returns the created options """ - sig = signature(command.callback) - # pass `sig` down to avoid having to call `signature(func)` another time, + parameters = get_signature_parameters(command.callback) + # pass `parameters` down to avoid having to call `get_signature_parameters(func)` another time, # which may cause side effects with deferred annotations and warnings - _, inter_param, params, injections = collect_params(command.callback, sig) + _, inter_param, params, injections = collect_params(command.callback, parameters) if inter_param is None: raise TypeError(f"Couldn't find an interaction parameter in {command.callback}") @@ -1120,10 +1107,7 @@ def expand_params(command: AnySlashCommand) -> List[Option]: if param.autocomplete: command.autocompleters[param.name] = param.autocomplete - if issubclass_( - get_origin(annot := sig.parameters[inter_param].annotation) or annot, - disnake.GuildCommandInteraction, - ): + if issubclass_(parameters[inter_param].annotation, disnake.GuildCommandInteraction): command._guild_only = True return [param.to_option() for param in params] @@ -1172,8 +1156,8 @@ def Param( .. versionchanged:: 2.5 Added support for localizations. - choices: Union[List[:class:`.OptionChoice`], List[Union[:class:`str`, :class:`int`]], Dict[:class:`str`, Union[:class:`str`, :class:`int`]]] - A list of choices for this option. + choices: Union[Sequence[:class:`.OptionChoice`], Sequence[Union[:class:`str`, :class:`int`, :class:`float`]], Mapping[:class:`str`, Union[:class:`str`, :class:`int`, :class:`float`]]] + The pre-defined choices for this slash command option. converter: Callable[[:class:`.ApplicationCommandInteraction`, Any], Any] A function that will convert the original input to a desired format. Kwarg aliases: ``conv``. @@ -1353,7 +1337,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): @@ -1405,12 +1389,11 @@ def register_injection( :class:`Injection` The injection being registered. """ - sig = signature(function) - tp = sig.return_annotation + tp = get_signature_return(function) if tp is inspect.Parameter.empty: raise TypeError("Injection must have a return annotation") if tp in ParamInfo.TYPES: raise TypeError("Injection cannot overwrite builtin types") - return Injection.register(function, sig.return_annotation, autocompleters=autocompleters) + return Injection.register(function, tp, autocompleters=autocompleters) diff --git a/disnake/ext/commands/slash_core.py b/disnake/ext/commands/slash_core.py index 1b318a21d0..a23cf86bd3 100644 --- a/disnake/ext/commands/slash_core.py +++ b/disnake/ext/commands/slash_core.py @@ -332,10 +332,12 @@ def parents( @property def description(self) -> str: + """:class:`str`: The slash sub command's description. Shorthand for :attr:`self.body.description <.Option.description>`.""" return self.body.description @property def body(self) -> Option: + """:class:`.Option`: The API representation for this slash sub command. Shorthand for :attr:`.SubCommand.option`""" return self.option async def _call_autocompleter( @@ -508,10 +510,12 @@ def _ensure_assignment_on_copy(self, other: SlashCommandT) -> SlashCommandT: @property def description(self) -> str: + """:class:`str`: The slash command's description. Shorthand for :attr:`self.body.description <.SlashCommand.description>`.""" return self.body.description @property def options(self) -> List[Option]: + """List[:class:`.Option`]: The list of options the slash command has. Shorthand for :attr:`self.body.options <.SlashCommand.options>`.""" return self.body.options def sub_command( @@ -666,7 +670,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 +699,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/flags.py b/disnake/flags.py index 66b9b4b369..63fc6bf2c6 100644 --- a/disnake/flags.py +++ b/disnake/flags.py @@ -329,7 +329,7 @@ class SystemChannelFlags(BaseFlags, inverted=True): to enable or disable. Arguments are applied in order, similar to :class:`Permissions`. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -491,7 +491,7 @@ class MessageFlags(BaseFlags): See :class:`SystemChannelFlags`. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -681,7 +681,7 @@ def is_voice_message(self): class PublicUserFlags(BaseFlags): """Wraps up the Discord User Public flags. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -930,7 +930,7 @@ class Intents(BaseFlags): .. versionadded:: 1.5 - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -1617,7 +1617,7 @@ class MemberCacheFlags(BaseFlags): .. versionadded:: 1.5 - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -1793,7 +1793,7 @@ def _voice_only(self): class ApplicationFlags(BaseFlags): """Wraps up the Discord Application flags. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -1968,7 +1968,7 @@ def application_command_badge(self): class ChannelFlags(BaseFlags): """Wraps up the Discord Channel flags. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -2081,7 +2081,7 @@ def require_tag(self): class AutoModKeywordPresets(ListBaseFlags): """Wraps up the pre-defined auto moderation keyword lists, provided by Discord. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -2194,7 +2194,7 @@ def slurs(self): class MemberFlags(BaseFlags): """Wraps up Discord Member flags. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -2296,7 +2296,7 @@ def started_onboarding(self): class RoleFlags(BaseFlags): """Wraps up Discord Role flags. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -2376,7 +2376,7 @@ def in_prompt(self): class AttachmentFlags(BaseFlags): """Wraps up Discord Attachment flags. - .. container:: operations + .. collapse:: operations .. describe:: x == y 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 888c7518d4..449f303c27 100644 --- a/disnake/guild.py +++ b/disnake/guild.py @@ -130,7 +130,7 @@ class Guild(Hashable): This is referred to as a "server" in the official Discord UI. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -2387,7 +2387,7 @@ async def create_scheduled_event( Creates a :class:`GuildScheduledEvent`. - You must have :attr:`.Permissions.manage_events` permission to do this. + You must have :attr:`~Permissions.manage_events` permission to do this. Based on the channel/entity type, there are different restrictions regarding other parameter values, as shown in this table: @@ -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] @@ -3278,7 +3274,7 @@ async def delete_sticker(self, sticker: Snowflake, *, reason: Optional[str] = No Raises ------ Forbidden - You are not allowed to delete stickers. + You are not allowed to delete this sticker. HTTPException An error occurred deleting the sticker. """ @@ -3433,7 +3429,7 @@ async def delete_emoji(self, emoji: Snowflake, *, reason: Optional[str] = None) Raises ------ Forbidden - You are not allowed to delete emojis. + You are not allowed to delete this emoji. HTTPException An error occurred deleting the emoji. """ diff --git a/disnake/guild_scheduled_event.py b/disnake/guild_scheduled_event.py index a9739b217a..1b01be136c 100644 --- a/disnake/guild_scheduled_event.py +++ b/disnake/guild_scheduled_event.py @@ -77,7 +77,7 @@ class GuildScheduledEvent(Hashable): .. versionadded:: 2.3 - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -253,7 +253,7 @@ async def delete(self) -> None: Deletes the guild scheduled event. - You must have :attr:`.Permissions.manage_events` permission to do this. + You must have :attr:`~Permissions.manage_events` permission to do this. Raises ------ @@ -382,7 +382,7 @@ async def edit( Edits the guild scheduled event. - You must have :attr:`.Permissions.manage_events` permission to do this. + You must have :attr:`~Permissions.manage_events` permission to do this. .. versionchanged:: 2.6 Updates must follow requirements of :func:`Guild.create_scheduled_event` @@ -536,7 +536,7 @@ async def start(self, *, reason: Optional[str] = None) -> GuildScheduledEvent: Changes the event status to :attr:`~GuildScheduledEventStatus.active`. - You must have :attr:`.Permissions.manage_events` permission to do this. + You must have :attr:`~Permissions.manage_events` permission to do this. .. versionadded:: 2.7 @@ -570,7 +570,7 @@ async def end(self, *, reason: Optional[str] = None) -> GuildScheduledEvent: Changes the event status to :attr:`~GuildScheduledEventStatus.completed`. - You must have :attr:`.Permissions.manage_events` permission to do this. + You must have :attr:`~Permissions.manage_events` permission to do this. .. versionadded:: 2.7 @@ -604,7 +604,7 @@ async def cancel(self, *, reason: Optional[str] = None) -> GuildScheduledEvent: Changes the event status to :attr:`~GuildScheduledEventStatus.cancelled`. - You must have :attr:`.Permissions.manage_events` permission to do this. + You must have :attr:`~Permissions.manage_events` permission to do this. .. versionadded:: 2.7 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..9a9ca32560 100644 --- a/disnake/i18n.py +++ b/disnake/i18n.py @@ -236,8 +236,9 @@ def _copy(self) -> LocalizationValue: def data(self) -> Optional[Dict[str, str]]: """Optional[Dict[:class:`str`, :class:`str`]]: A dict with a locale -> localization mapping, if available.""" if self._data is MISSING: + # This will happen when `_link(store)` hasn't been called yet, which *shouldn't* occur under normal circumstances. warnings.warn( - f"value ('{self._key}') was never localized, this is likely a library bug", + f"Localization value ('{self._key}') was never linked to bot; this may be a library bug.", LocalizationWarning, stacklevel=2, ) @@ -245,6 +246,10 @@ def data(self) -> Optional[Dict[str, str]]: return self._data def __eq__(self, other) -> bool: + # if both are pending, compare keys instead + if self._data is MISSING and other._data is MISSING: + return self._key == other._key + d1 = self.data d2 = other.data # consider values equal if they're both falsy, or actually equal @@ -409,7 +414,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..f0058d2cc8 100644 --- a/disnake/interactions/base.py +++ b/disnake/interactions/base.py @@ -1260,8 +1260,8 @@ async def autocomplete(self, *, choices: Choices) -> None: Parameters ---------- - choices: Union[List[:class:`OptionChoice`], List[Union[:class:`str`, :class:`int`]], Dict[:class:`str`, Union[:class:`str`, :class:`int`]]] - The list of choices to suggest. + choices: Union[Sequence[:class:`OptionChoice`], Sequence[Union[:class:`str`, :class:`int`, :class:`float`]], Mapping[:class:`str`, Union[:class:`str`, :class:`int`, :class:`float`]]] + The choices to suggest. Raises ------ @@ -1277,6 +1277,9 @@ async def autocomplete(self, *, choices: Choices) -> None: if isinstance(choices, Mapping): choices_data = [{"name": n, "value": v} for n, v in choices.items()] else: + if isinstance(choices, str): # str matches `Sequence[str]`, but isn't meant to be used + raise TypeError("choices argument should be a list/sequence or dict, not str") + choices_data = [] value: ApplicationCommandOptionChoicePayload i18n = self._parent.client.i18n @@ -1855,7 +1858,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/invite.py b/disnake/invite.py index 2d95ea6d8a..a936c832b1 100644 --- a/disnake/invite.py +++ b/disnake/invite.py @@ -48,7 +48,7 @@ class PartialInviteChannel: guild the :class:`Invite` resolves to. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -137,7 +137,7 @@ class PartialInviteGuild: This model will be given when the user is not part of the guild the :class:`Invite` resolves to. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -256,7 +256,7 @@ class Invite(Hashable): Depending on the way this object was created, some of the attributes can have a value of ``None`` (see table below). - .. container:: operations + .. collapse:: operations .. describe:: x == y 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/member.py b/disnake/member.py index 25886079eb..fb1a98e6c8 100644 --- a/disnake/member.py +++ b/disnake/member.py @@ -212,7 +212,7 @@ class Member(disnake.abc.Messageable, _UserTag): This implements a lot of the functionality of :class:`User`. - .. container:: operations + .. collapse:: operations .. describe:: x == y diff --git a/disnake/message.py b/disnake/message.py index 21f59e269e..e3967e1160 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -220,7 +220,7 @@ async def _edit_handler( class Attachment(Hashable): """Represents an attachment from Discord. - .. container:: operations + .. collapse:: operations .. describe:: str(x) @@ -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 @@ -766,7 +767,7 @@ def flatten_handlers(cls): class Message(Hashable): """Represents a message from Discord. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -2177,7 +2178,7 @@ class PartialMessage(Hashable): .. versionadded:: 1.6 - .. container:: operations + .. collapse:: operations .. describe:: x == y diff --git a/disnake/object.py b/disnake/object.py index 9af6a758b7..cd3048b6b1 100644 --- a/disnake/object.py +++ b/disnake/object.py @@ -29,7 +29,7 @@ class Object(Hashable): receive this class rather than the actual data class. These cases are extremely rare. - .. container:: operations + .. collapse:: operations .. describe:: x == y diff --git a/disnake/opus.py b/disnake/opus.py index 596306dd6d..86a3ccbb70 100644 --- a/disnake/opus.py +++ b/disnake/opus.py @@ -234,12 +234,12 @@ def _load_default() -> bool: _bitness = struct.calcsize("P") * 8 _target = "x64" if _bitness > 32 else "x86" _filename = os.path.join(_basedir, "bin", f"libopus-0.{_target}.dll") - _lib = libopus_loader(_filename) # noqa: PLW0603 + _lib = libopus_loader(_filename) else: path = ctypes.util.find_library("opus") if not path: raise AssertionError("could not find the opus library") - _lib = libopus_loader(path) # noqa: PLW0603 + _lib = libopus_loader(path) except Exception: _lib = MISSING diff --git a/disnake/partial_emoji.py b/disnake/partial_emoji.py index ab124d28e1..92656bb314 100644 --- a/disnake/partial_emoji.py +++ b/disnake/partial_emoji.py @@ -38,7 +38,7 @@ class PartialEmoji(_EmojiTag, AssetMixin): - "Raw" data events such as :func:`on_raw_reaction_add` - Custom emoji that the bot cannot see from e.g. :attr:`Message.reactions` - .. container:: operations + .. collapse:: operations .. describe:: x == y diff --git a/disnake/permissions.py b/disnake/permissions.py index 8046f14d4a..edad50e84d 100644 --- a/disnake/permissions.py +++ b/disnake/permissions.py @@ -76,7 +76,7 @@ class Permissions(BaseFlags): You can now use keyword arguments to initialize :class:`Permissions` similar to :meth:`update`. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -164,7 +164,9 @@ def __init__( ban_members: bool = ..., change_nickname: bool = ..., connect: bool = ..., + create_events: bool = ..., create_forum_threads: bool = ..., + create_guild_expressions: bool = ..., create_instant_invite: bool = ..., create_private_threads: bool = ..., create_public_threads: bool = ..., @@ -291,6 +293,7 @@ def all_channel(cls) -> Self: ``True`` and the guild-specific ones set to ``False``. The guild-specific permissions are currently: + - :attr:`create_guild_expressions` - :attr:`manage_guild_expressions` - :attr:`view_audit_log` - :attr:`view_guild_insights` @@ -316,12 +319,16 @@ def all_channel(cls) -> Self: .. versionchanged:: 2.9 Added :attr:`use_soundboard` and :attr:`send_voice_messages` permissions. + + .. versionchanged:: 2.10 + Added :attr:`create_events` permission. """ instance = cls.all() instance.update( administrator=False, ban_members=False, change_nickname=False, + create_guild_expressions=False, kick_members=False, manage_guild=False, manage_guild_expressions=False, @@ -347,11 +354,15 @@ def general(cls) -> Self: .. versionchanged:: 2.9 Added :attr:`view_creator_monetization_analytics` permission. + + .. versionchanged:: 2.10 + Added :attr:`create_guild_expressions` permission. """ return cls( view_channel=True, manage_channels=True, manage_roles=True, + create_guild_expressions=True, manage_guild_expressions=True, view_audit_log=True, view_guild_insights=True, @@ -475,8 +486,12 @@ def events(cls) -> Self: "Events" permissions from the official Discord UI set to ``True``. .. versionadded:: 2.4 + + .. versionchanged:: 2.10 + Added :attr:`create_events` permission. """ return cls( + create_events=True, manage_events=True, ) @@ -532,7 +547,9 @@ def update( ban_members: bool = ..., change_nickname: bool = ..., connect: bool = ..., + create_events: bool = ..., create_forum_threads: bool = ..., + create_guild_expressions: bool = ..., create_instant_invite: bool = ..., create_private_threads: bool = ..., create_public_threads: bool = ..., @@ -830,8 +847,10 @@ def manage_webhooks(self) -> int: @flag_value def manage_guild_expressions(self) -> int: - """:class:`bool`: Returns ``True`` if a user can create, edit, or delete - emojis, stickers, and soundboard sounds. + """:class:`bool`: Returns ``True`` if a user can edit or delete + emojis, stickers, and soundboard sounds created by all users. + + See also :attr:`create_guild_expressions`. .. versionadded:: 2.9 """ @@ -879,7 +898,10 @@ def request_to_speak(self) -> int: @flag_value def manage_events(self) -> int: - """:class:`bool`: Returns ``True`` if a user can manage guild events. + """:class:`bool`: Returns ``True`` if a user can edit or delete guild scheduled events + created by all users. + + See also :attr:`create_events`. .. versionadded:: 2.0 """ @@ -978,6 +1000,28 @@ def use_soundboard(self) -> int: """ return 1 << 42 + @flag_value + def create_guild_expressions(self) -> int: + """:class:`bool`: Returns ``True`` if a user can create emojis, stickers, + and soundboard sounds, as well as edit and delete the ones they created. + + See also :attr:`manage_guild_expressions`. + + .. versionadded:: 2.10 + """ + return 1 << 43 + + @flag_value + def create_events(self) -> int: + """:class:`bool`: Returns ``True`` if a user can create guild scheduled events, + as well as edit and delete the ones they created. + + See also :attr:`manage_events`. + + .. versionadded:: 2.10 + """ + return 1 << 44 + @flag_value def use_external_sounds(self) -> int: """:class:`bool`: Returns ``True`` if a user can use custom soundboard sounds from other guilds. @@ -1036,7 +1080,7 @@ class PermissionOverwrite: The values supported by this are the same as :class:`Permissions` with the added possibility of it being set to ``None``. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -1066,7 +1110,9 @@ class PermissionOverwrite: ban_members: Optional[bool] change_nickname: Optional[bool] connect: Optional[bool] + create_events: Optional[bool] create_forum_threads: Optional[bool] + create_guild_expressions: Optional[bool] create_instant_invite: Optional[bool] create_private_threads: Optional[bool] create_public_threads: Optional[bool] @@ -1130,7 +1176,9 @@ def __init__( ban_members: Optional[bool] = ..., change_nickname: Optional[bool] = ..., connect: Optional[bool] = ..., + create_events: Optional[bool] = ..., create_forum_threads: Optional[bool] = ..., + create_guild_expressions: Optional[bool] = ..., create_instant_invite: Optional[bool] = ..., create_private_threads: Optional[bool] = ..., create_public_threads: Optional[bool] = ..., @@ -1261,7 +1309,9 @@ def update( ban_members: Optional[bool] = ..., change_nickname: Optional[bool] = ..., connect: Optional[bool] = ..., + create_events: Optional[bool] = ..., create_forum_threads: Optional[bool] = ..., + create_guild_expressions: Optional[bool] = ..., create_instant_invite: Optional[bool] = ..., create_private_threads: Optional[bool] = ..., create_public_threads: Optional[bool] = ..., diff --git a/disnake/reaction.py b/disnake/reaction.py index 5a3c784627..0720759f6a 100644 --- a/disnake/reaction.py +++ b/disnake/reaction.py @@ -22,7 +22,7 @@ class Reaction: Depending on the way this object was created, some of the attributes can have a value of ``None``. - .. container:: operations + .. collapse:: operations .. describe:: x == y diff --git a/disnake/role.py b/disnake/role.py index addd6b7551..89fa55804f 100644 --- a/disnake/role.py +++ b/disnake/role.py @@ -140,7 +140,7 @@ def __repr__(self) -> str: class Role(Hashable): """Represents a Discord role in a :class:`Guild`. - .. container:: operations + .. collapse:: operations .. describe:: x == y 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/stage_instance.py b/disnake/stage_instance.py index 08f50dc3e1..deff882916 100644 --- a/disnake/stage_instance.py +++ b/disnake/stage_instance.py @@ -24,7 +24,7 @@ class StageInstance(Hashable): .. versionadded:: 2.0 - .. container:: operations + .. collapse:: operations .. describe:: x == y 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/sticker.py b/disnake/sticker.py index 01ce53b9d3..be7479cf2b 100644 --- a/disnake/sticker.py +++ b/disnake/sticker.py @@ -44,7 +44,7 @@ class StickerPack(Hashable): .. versionchanged:: 2.8 :attr:`cover_sticker_id`, :attr:`cover_sticker` and :attr:`banner` are now optional. - .. container:: operations + .. collapse:: operations .. describe:: str(x) @@ -163,7 +163,7 @@ class StickerItem(_StickerTag): .. versionadded:: 2.0 - .. container:: operations + .. collapse:: operations .. describe:: str(x) @@ -226,7 +226,7 @@ class Sticker(_StickerTag): .. versionadded:: 1.6 - .. container:: operations + .. collapse:: operations .. describe:: str(x) @@ -283,7 +283,7 @@ class StandardSticker(Sticker): .. versionadded:: 2.0 - .. container:: operations + .. collapse:: operations .. describe:: str(x) @@ -362,7 +362,7 @@ class GuildSticker(Sticker): .. versionadded:: 2.0 - .. container:: operations + .. collapse:: operations .. describe:: str(x) @@ -450,7 +450,7 @@ async def edit( Raises ------ Forbidden - You are not allowed to edit stickers. + You are not allowed to edit this sticker. HTTPException An error occurred editing the sticker. @@ -498,7 +498,7 @@ async def delete(self, *, reason: Optional[str] = None) -> None: Raises ------ Forbidden - You are not allowed to delete stickers. + You are not allowed to delete this sticker. HTTPException An error occurred deleting the sticker. """ diff --git a/disnake/team.py b/disnake/team.py index 1034904cd9..a1f126304e 100644 --- a/disnake/team.py +++ b/disnake/team.py @@ -7,7 +7,7 @@ from . import utils from .asset import Asset -from .enums import TeamMembershipState, try_enum +from .enums import TeamMemberRole, TeamMembershipState, try_enum from .user import BaseUser if TYPE_CHECKING: @@ -21,7 +21,8 @@ class Team: - """Represents an application team for a bot provided by Discord. + """Represents an application team. + Teams are groups of users who share access to an application's configuration. Attributes ---------- @@ -30,7 +31,7 @@ class Team: name: :class:`str` The team name. owner_id: :class:`int` - The team's owner ID. + The team owner's ID. members: List[:class:`TeamMember`] A list of the members in the team. @@ -44,7 +45,7 @@ def __init__(self, state: ConnectionState, data: TeamPayload) -> None: self.id: int = int(data["id"]) self.name: str = data["name"] - self._icon: Optional[str] = data["icon"] + self._icon: Optional[str] = data.get("icon") self.owner_id: Optional[int] = utils._get_as_snowflake(data, "owner_user_id") self.members: List[TeamMember] = [ TeamMember(self, self._state, member) for member in data["members"] @@ -77,7 +78,7 @@ def owner(self) -> Optional[TeamMember]: class TeamMember(BaseUser): """Represents a team member in a team. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -113,29 +114,27 @@ class TeamMember(BaseUser): See the `help article `__ for details. global_name: Optional[:class:`str`] - The team members's global display name, if set. + The team member's global display name, if set. This takes precedence over :attr:`.name` when shown. .. versionadded:: 2.9 - avatar: Optional[:class:`str`] - The avatar hash the team member has. Could be None. - bot: :class:`bool` - Specifies if the user is a bot account. team: :class:`Team` The team that the member is from. membership_state: :class:`TeamMembershipState` - The membership state of the member (e.g. invited or accepted) + The membership state of the member (e.g. invited or accepted). + role: :class:`TeamMemberRole` + The role of the team member in the team. """ - __slots__ = ("team", "membership_state", "permissions") + __slots__ = ("team", "membership_state", "role") def __init__(self, team: Team, state: ConnectionState, data: TeamMemberPayload) -> None: self.team: Team = team self.membership_state: TeamMembershipState = try_enum( TeamMembershipState, data["membership_state"] ) - self.permissions: List[str] = data["permissions"] + self.role: TeamMemberRole = try_enum(TeamMemberRole, data.get("role")) super().__init__(state=state, data=data["user"]) def __repr__(self) -> str: diff --git a/disnake/threads.py b/disnake/threads.py index 2457c5a879..fb0b2add92 100644 --- a/disnake/threads.py +++ b/disnake/threads.py @@ -12,6 +12,7 @@ from .flags import ChannelFlags from .mixins import Hashable from .partial_emoji import PartialEmoji, _EmojiTag +from .permissions import Permissions from .utils import MISSING, _get_as_snowflake, _unique, parse_time, snowflake_time __all__ = ( @@ -31,7 +32,6 @@ from .guild import Guild from .member import Member from .message import Message, PartialMessage - from .permissions import Permissions from .role import Role from .state import ConnectionState from .types.snowflake import SnowflakeList @@ -54,7 +54,7 @@ class Thread(Messageable, Hashable): """Represents a Discord thread. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -1018,7 +1018,7 @@ def _pop_member(self, member_id: int) -> Optional[ThreadMember]: class ThreadMember(Hashable): """Represents a Discord thread member. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -1092,7 +1092,7 @@ def thread(self) -> Thread: class ForumTag(Hashable): """Represents a tag for threads in forum channels. - .. container:: operations + .. collapse:: operations .. describe:: x == y 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/team.py b/disnake/types/team.py index 5662365e03..0829c18b5c 100644 --- a/disnake/types/team.py +++ b/disnake/types/team.py @@ -8,13 +8,14 @@ from .user import PartialUser TeamMembershipState = Literal[1, 2] +TeamMemberRole = Literal["admin", "developer", "read_only"] class TeamMember(TypedDict): - user: PartialUser membership_state: TeamMembershipState - permissions: List[str] team_id: Snowflake + user: PartialUser + role: TeamMemberRole class Team(TypedDict): 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 b8473badb0..21ea01cb74 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -91,7 +91,7 @@ class ActionRow(Generic[UIComponentT]): """Represents a UI action row. Useful for lower level component manipulation. - .. container:: operations + .. collapse:: operations .. describe:: x[i] @@ -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 8bb1e60ff2..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. @@ -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/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 a455172799..57dd9cfbe9 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, ...]] = 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..860903f7f1 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, ...]] = 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..fe2da2f97a 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, ...]] = 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..3eeedc1f22 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, ...]] = 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..4868894a83 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, ...]] = 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/disnake/user.py b/disnake/user.py index b2b05acb54..4326016100 100644 --- a/disnake/user.py +++ b/disnake/user.py @@ -281,7 +281,7 @@ def mentioned_in(self, message: Message) -> bool: class ClientUser(BaseUser): """Represents your Discord user. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -419,7 +419,7 @@ async def edit( class User(BaseUser, disnake.abc.Messageable): """Represents a Discord user. - .. container:: operations + .. collapse:: operations .. describe:: x == y diff --git a/disnake/utils.py b/disnake/utils.py index 15fd82ff57..f4c9e5fb9a 100644 --- a/disnake/utils.py +++ b/disnake/utils.py @@ -6,11 +6,13 @@ import asyncio import datetime import functools +import inspect import json import os import pkgutil import re import sys +import types import unicodedata import warnings from base64 import b64encode @@ -142,6 +144,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]): @@ -497,7 +500,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"): @@ -510,14 +513,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: @@ -546,7 +549,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 @@ -579,7 +582,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 @@ -828,7 +832,7 @@ def replacement(match): regex = _MARKDOWN_STOCK_REGEX if ignore_links: regex = f"(?:{_URL_REGEX}|{regex})" - return re.sub(regex, replacement, text, 0, re.MULTILINE) + return re.sub(regex, replacement, text, flags=re.MULTILINE) def escape_markdown(text: str, *, as_needed: bool = False, ignore_links: bool = True) -> str: @@ -867,7 +871,7 @@ def replacement(match): regex = _MARKDOWN_STOCK_REGEX if ignore_links: regex = f"(?:{_URL_REGEX}|{regex})" - return re.sub(regex, replacement, text, 0, re.MULTILINE) + return re.sub(regex, replacement, text, flags=re.MULTILINE) else: text = re.sub(r"\\", r"\\\\", text) return _MARKDOWN_ESCAPE_REGEX.sub(r"\\\1", text) @@ -1125,6 +1129,24 @@ def normalise_optional_params(parameters: Iterable[Any]) -> Tuple[Any, ...]: return tuple(p for p in parameters if p is not none_cls) + (none_cls,) +def _resolve_typealiastype( + tp: Any, globals: Dict[str, Any], locals: Dict[str, Any], cache: Dict[str, Any] +): + # Use __module__ to get the (global) namespace in which the type alias was defined. + if mod := sys.modules.get(tp.__module__): + mod_globals = mod.__dict__ + if mod_globals is not globals or mod_globals is not locals: + # if the namespace changed (usually when a TypeAliasType was imported from a different module), + # drop the cache since names can resolve differently now + cache = {} + globals = locals = mod_globals + + # Accessing `__value__` automatically evaluates the type alias in the annotation scope. + # (recurse to resolve possible forwardrefs, aliases, etc.) + return evaluate_annotation(tp.__value__, globals, locals, cache) + + +# FIXME: this should be split up into smaller functions for clarity and easier maintenance def evaluate_annotation( tp: Any, globals: Dict[str, Any], @@ -1141,29 +1163,40 @@ def evaluate_annotation( if implicit_str and isinstance(tp, str): if tp in cache: return cache[tp] - evaluated = eval( # noqa: PGH001 # this is how annotations are supposed to be unstringifed - tp, globals, locals - ) + + # this is how annotations are supposed to be unstringifed + evaluated = eval(tp, globals, locals) # noqa: PGH001, S307 + # recurse to resolve nested args further + evaluated = evaluate_annotation(evaluated, globals, locals, cache) + cache[tp] = evaluated - return evaluate_annotation(evaluated, globals, locals, cache) + return evaluated + # GenericAlias / UnionType if hasattr(tp, "__args__"): - implicit_str = True - is_literal = False - orig_args = args = tp.__args__ if not hasattr(tp, "__origin__"): if tp.__class__ is UnionType: - converted = Union[args] # type: ignore + converted = Union[tp.__args__] # type: ignore return evaluate_annotation(converted, globals, locals, cache) return tp - if tp.__origin__ is Union: + + implicit_str = True + is_literal = False + orig_args = args = tp.__args__ + orig_origin = origin = tp.__origin__ + + # origin can be a TypeAliasType too, resolve it and continue + if hasattr(origin, "__value__"): + origin = _resolve_typealiastype(origin, globals, locals, cache) + + if origin is Union: try: if args.index(type(None)) != len(args) - 1: args = normalise_optional_params(tp.__args__) except ValueError: pass - if tp.__origin__ is Literal: + if origin is Literal: if not PY_310: args = flatten_literal_params(tp.__args__) implicit_str = False @@ -1179,13 +1212,21 @@ def evaluate_annotation( ): raise TypeError("Literal arguments must be of type str, int, bool, or NoneType.") + if origin != orig_origin: + # we can't use `copy_with` in this case, so just skip all of the following logic + return origin[evaluated_args] + if evaluated_args == orig_args: return tp try: return tp.copy_with(evaluated_args) except AttributeError: - return tp.__origin__[evaluated_args] + return origin[evaluated_args] + + # TypeAliasType, 3.12+ + if hasattr(tp, "__value__"): + return _resolve_typealiastype(tp, globals, locals, cache) return tp @@ -1207,6 +1248,137 @@ def resolve_annotation( return evaluate_annotation(annotation, globalns, locals, cache) +def unwrap_function(function: Callable[..., Any]) -> Callable[..., Any]: + partial = functools.partial + while True: + if hasattr(function, "__wrapped__"): + function = function.__wrapped__ + elif isinstance(function, partial): + function = function.func + else: + return function + + +def _get_function_globals(function: Callable[..., Any]) -> Dict[str, Any]: + unwrap = unwrap_function(function) + try: + return unwrap.__globals__ + except AttributeError: + return {} + + +_inspect_empty = inspect.Parameter.empty + + +def get_signature_parameters( + 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: + globalns = _get_function_globals(function) + + params: Dict[str, inspect.Parameter] = {} + 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 iterator: + annotation = parameter.annotation + if annotation is _inspect_empty: + params[name] = parameter + continue + + if annotation is None: + annotation = type(None) + else: + annotation = evaluate_annotation(annotation, globalns, globalns, cache) + + params[name] = parameter.replace(annotation=annotation) + + return params + + +def get_signature_return(function: Callable[..., Any]) -> Any: + signature = inspect.signature(function) + + # same as parameters above, but for the return annotation + ret = signature.return_annotation + if ret is not _inspect_empty: + if ret is None: + ret = type(None) + else: + globalns = _get_function_globals(function) + ret = evaluate_annotation(ret, globalns, globalns, {}) + + 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/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/disnake/voice_region.py b/disnake/voice_region.py index 6c957d5e04..b08689db5b 100644 --- a/disnake/voice_region.py +++ b/disnake/voice_region.py @@ -14,7 +14,7 @@ class VoiceRegion: """Represents a Discord voice region. - .. container:: operations + .. collapse:: operations .. describe:: x == y diff --git a/disnake/webhook/async_.py b/disnake/webhook/async_.py index edd9ec3dcd..851a88dfee 100644 --- a/disnake/webhook/async_.py +++ b/disnake/webhook/async_.py @@ -63,7 +63,7 @@ from ..mentions import AllowedMentions from ..message import Attachment from ..state import ConnectionState - from ..sticker import GuildSticker, StickerItem + from ..sticker import GuildSticker, StandardSticker, StickerItem from ..types.message import Message as MessagePayload from ..types.webhook import Webhook as WebhookPayload from ..ui.action_row import Components @@ -491,7 +491,7 @@ def handle_message_parameters_dict( components: Optional[Components[MessageUIComponent]] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, previous_allowed_mentions: Optional[AllowedMentions] = None, - stickers: Sequence[Union[GuildSticker, StickerItem]] = MISSING, + stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = MISSING, thread_name: Optional[str] = None, ) -> DictPayloadParameters: if files is not MISSING and file is not MISSING: @@ -578,7 +578,7 @@ def handle_message_parameters( components: Optional[Components[MessageUIComponent]] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, previous_allowed_mentions: Optional[AllowedMentions] = None, - stickers: Sequence[Union[GuildSticker, StickerItem]] = MISSING, + stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = MISSING, thread_name: Optional[str] = None, ) -> PayloadParameters: params = handle_message_parameters_dict( @@ -1034,7 +1034,7 @@ async def foo(): For a synchronous counterpart, see :class:`SyncWebhook`. - .. container:: operations + .. collapse:: operations .. describe:: x == y diff --git a/disnake/webhook/sync.py b/disnake/webhook/sync.py index 0d2ba42b6c..b1debb9cf3 100644 --- a/disnake/webhook/sync.py +++ b/disnake/webhook/sync.py @@ -510,7 +510,7 @@ class SyncWebhook(BaseWebhook): For an asynchronous counterpart, see :class:`Webhook`. - .. container:: operations + .. collapse:: operations .. describe:: x == y diff --git a/disnake/widget.py b/disnake/widget.py index d5056d82dc..4293985a36 100644 --- a/disnake/widget.py +++ b/disnake/widget.py @@ -34,7 +34,7 @@ class WidgetChannel: """Represents a "partial" widget channel. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -89,7 +89,7 @@ def created_at(self) -> datetime.datetime: class WidgetMember(BaseUser): """Represents a "partial" member of the widget's guild. - .. container:: operations + .. collapse:: operations .. describe:: x == y @@ -262,7 +262,7 @@ async def edit( class Widget: """Represents a :class:`Guild` widget. - .. container:: operations + .. collapse:: operations .. describe:: x == y diff --git a/docs/_static/style.css b/docs/_static/style.css index a54a5ecf52..b89e43a930 100644 --- a/docs/_static/style.css +++ b/docs/_static/style.css @@ -1313,21 +1313,20 @@ rect.highlighted { fill: var(--highlighted-text); } -.container.operations { +details.operations { padding: 10px; border: 1px solid var(--codeblock-border); margin-bottom: 20px; } -.container.operations::before { - content: 'Supported Operations'; - color: var(--main-big-headers-text); - display: block; - padding-bottom: 0.5em; +details.operations dl { + margin-top: 15px; + margin-bottom: 15px; } -.container.operations > dl.describe > dt { - background-color: var(--api-entry-background); +details.operations > summary::after { + content: 'Supported Operations'; + color: var(--main-big-headers-text); } .table-wrapper { diff --git a/docs/api/app_commands.rst b/docs/api/app_commands.rst index 1d26bee539..a55e3670ab 100644 --- a/docs/api/app_commands.rst +++ b/docs/api/app_commands.rst @@ -97,7 +97,7 @@ Option .. attributetable:: Option -.. autoclass:: Option() +.. autoclass:: Option :members: OptionChoice diff --git a/docs/api/app_info.rst b/docs/api/app_info.rst index eed2a74143..7aa6c4148f 100644 --- a/docs/api/app_info.rst +++ b/docs/api/app_info.rst @@ -49,6 +49,7 @@ TeamMember .. autoclass:: TeamMember() :members: + :inherited-members: Data Classes ------------ @@ -89,6 +90,27 @@ TeamMembershipState Represents a member currently in the team. +TeamMemberRole +~~~~~~~~~~~~~~ + +.. class:: TeamMemberRole + + Represents the role of a team member retrieved through :func:`Client.application_info`. + + .. versionadded:: 2.10 + + .. attribute:: admin + + Admins have the most permissions. An admin can only take destructive actions on the team or team-owned apps if they are the team owner. + + .. attribute:: developer + + Developers can access information about a team and team-owned applications, and take limited actions on them, like configuring interaction endpoints or resetting the bot token. + + .. attribute:: read_only + + Read-only members can access information about a team and team-owned applications. + ApplicationRoleConnectionMetadataType ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/api/audit_logs.rst b/docs/api/audit_logs.rst index 1052c610b4..e4cb0573d2 100644 --- a/docs/api/audit_logs.rst +++ b/docs/api/audit_logs.rst @@ -93,7 +93,7 @@ AuditLogDiff on the action being done, check the documentation for :class:`AuditLogAction`, otherwise check the documentation below for all attributes that are possible. - .. container:: operations + .. collapse:: operations .. describe:: iter(diff) diff --git a/docs/api/guilds.rst b/docs/api/guilds.rst index 85f8d3d494..72a7ad6f84 100644 --- a/docs/api/guilds.rst +++ b/docs/api/guilds.rst @@ -131,7 +131,7 @@ VerificationLevel Specifies a :class:`Guild`\'s verification level, which is the criteria in which a member must meet before being able to send messages to the guild. - .. container:: operations + .. collapse:: operations .. versionadded:: 2.0 @@ -180,9 +180,7 @@ NotificationLevel Specifies whether a :class:`Guild` has notifications on for all messages or mentions only by default. - .. container:: operations - - .. versionadded:: 2.0 + .. collapse:: operations .. describe:: x == y @@ -219,9 +217,7 @@ ContentFilter learning algorithms that Discord uses to detect if an image contains NSFW content. - .. container:: operations - - .. versionadded:: 2.0 + .. collapse:: operations .. describe:: x == y @@ -261,7 +257,7 @@ NSFWLevel .. versionadded:: 2.0 - .. container:: operations + .. collapse:: operations .. describe:: x == y diff --git a/docs/api/messages.rst b/docs/api/messages.rst index 123260f167..3031d955d9 100644 --- a/docs/api/messages.rst +++ b/docs/api/messages.rst @@ -188,14 +188,14 @@ MessageType Specifies the type of :class:`Message`. This is used to denote if a message is to be interpreted as a system message or a regular message. - .. container:: operations + .. collapse:: operations - .. describe:: x == y + .. describe:: x == y - Checks if two messages are equal. - .. describe:: x != y + Checks if two messages are equal. + .. describe:: x != y - Checks if two messages are not equal. + Checks if two messages are not equal. .. attribute:: default diff --git a/docs/api/misc.rst b/docs/api/misc.rst index c49854c289..83ee3298d8 100644 --- a/docs/api/misc.rst +++ b/docs/api/misc.rst @@ -18,7 +18,7 @@ AsyncIterator Represents the "AsyncIterator" concept. Note that no such class exists, it is purely abstract. - .. container:: operations + .. collapse:: operations .. describe:: async for x in y 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/docs/conf.py b/docs/conf.py index 355f977465..5944191079 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -52,6 +52,7 @@ "exception_hierarchy", "attributetable", "resourcelinks", + "collapse", "nitpick_file_ignorer", ] 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: diff --git a/docs/extensions/attributetable.py b/docs/extensions/attributetable.py index 1d6a2cb663..d718c73604 100644 --- a/docs/extensions/attributetable.py +++ b/docs/extensions/attributetable.py @@ -6,7 +6,7 @@ import inspect import re from collections import defaultdict -from typing import TYPE_CHECKING, DefaultDict, Dict, List, NamedTuple, Optional, Tuple +from typing import TYPE_CHECKING, ClassVar, DefaultDict, Dict, List, NamedTuple, Optional, Tuple from docutils import nodes from sphinx import addnodes @@ -14,11 +14,13 @@ from sphinx.util.docutils import SphinxDirective if TYPE_CHECKING: - from _types import SphinxExtensionMeta from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment + from sphinx.util.typing import OptionSpec from sphinx.writers.html import HTMLTranslator + from ._types import SphinxExtensionMeta + class attributetable(nodes.General, nodes.Element): pass @@ -100,7 +102,7 @@ class PyAttributeTable(SphinxDirective): required_arguments = 1 optional_arguments = 0 final_argument_whitespace = False - option_spec = {} + option_spec: ClassVar[OptionSpec] = {} def parse_name(self, content: str) -> Tuple[str, Optional[str]]: match = _name_parser_regex.match(content) diff --git a/docs/extensions/builder.py b/docs/extensions/builder.py index 6f1a5493d4..61e366d2ca 100644 --- a/docs/extensions/builder.py +++ b/docs/extensions/builder.py @@ -8,12 +8,13 @@ from sphinx.environment.adapters.indexentries import IndexEntries if TYPE_CHECKING: - from _types import SphinxExtensionMeta from docutils import nodes from sphinx.application import Sphinx from sphinx.config import Config from sphinx.writers.html5 import HTML5Translator + from ._types import SphinxExtensionMeta + if TYPE_CHECKING: translator_base = HTML5Translator else: @@ -64,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/docs/extensions/collapse.py b/docs/extensions/collapse.py new file mode 100644 index 0000000000..cde568aea2 --- /dev/null +++ b/docs/extensions/collapse.py @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from docutils import nodes +from docutils.parsers.rst import Directive, directives + +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.util.typing import OptionSpec + from sphinx.writers.html import HTMLTranslator + + from ._types import SphinxExtensionMeta + + +class collapse(nodes.General, nodes.Element): + pass + + +def visit_collapse_node(self: HTMLTranslator, node: nodes.Element) -> None: + attrs = {"open": ""} if node["open"] else {} + self.body.append(self.starttag(node, "details", **attrs)) + self.body.append("") + + +def depart_collapse_node(self: HTMLTranslator, node: nodes.Element) -> None: + self.body.append("\n") + + +class CollapseDirective(Directive): + has_content = True + + optional_arguments = 1 + final_argument_whitespace = True + + option_spec: ClassVar[OptionSpec] = {"open": directives.flag} + + def run(self): + self.assert_has_content() + node = collapse( + "\n".join(self.content), + open="open" in self.options, + ) + + classes = directives.class_option(self.arguments[0] if self.arguments else "") + node["classes"].extend(classes) + + self.state.nested_parse(self.content, self.content_offset, node) + return [node] + + +def setup(app: Sphinx) -> SphinxExtensionMeta: + app.add_node(collapse, html=(visit_collapse_node, depart_collapse_node)) + app.add_directive("collapse", CollapseDirective) + + return { + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/extensions/exception_hierarchy.py b/docs/extensions/exception_hierarchy.py index 147a175af2..67040643ae 100644 --- a/docs/extensions/exception_hierarchy.py +++ b/docs/extensions/exception_hierarchy.py @@ -7,10 +7,11 @@ from docutils.parsers.rst import Directive if TYPE_CHECKING: - from _types import SphinxExtensionMeta from sphinx.application import Sphinx from sphinx.writers.html import HTMLTranslator + from ._types import SphinxExtensionMeta + class exception_hierarchy(nodes.General, nodes.Element): pass diff --git a/docs/extensions/fulltoc.py b/docs/extensions/fulltoc.py index 1d7523e52a..e35cd79514 100644 --- a/docs/extensions/fulltoc.py +++ b/docs/extensions/fulltoc.py @@ -31,7 +31,6 @@ from typing import TYPE_CHECKING, List, cast -from _types import SphinxExtensionMeta from docutils import nodes from sphinx import addnodes @@ -40,6 +39,8 @@ from sphinx.builders.html import StandaloneHTMLBuilder from sphinx.environment import BuildEnvironment + from ._types import SphinxExtensionMeta + # {prefix: index_doc} mapping # Any document that matches `prefix` will use `index_doc`'s toctree instead. GROUPED_SECTIONS = {"api/": "api/index", "ext/commands/api/": "ext/commands/api/index"} diff --git a/docs/extensions/nitpick_file_ignorer.py b/docs/extensions/nitpick_file_ignorer.py index da967f9d92..cc9eab588f 100644 --- a/docs/extensions/nitpick_file_ignorer.py +++ b/docs/extensions/nitpick_file_ignorer.py @@ -7,9 +7,10 @@ from sphinx.util import logging as sphinx_logging if TYPE_CHECKING: - from _types import SphinxExtensionMeta from sphinx.application import Sphinx + from ._types import SphinxExtensionMeta + class NitpickFileIgnorer(logging.Filter): def __init__(self, app: Sphinx) -> None: diff --git a/docs/extensions/redirects.py b/docs/extensions/redirects.py index fea63483be..44d1c16bef 100644 --- a/docs/extensions/redirects.py +++ b/docs/extensions/redirects.py @@ -1,13 +1,16 @@ # SPDX-License-Identifier: MIT +from __future__ import annotations import json from pathlib import Path -from typing import Dict +from typing import TYPE_CHECKING, Dict -from _types import SphinxExtensionMeta from sphinx.application import Sphinx from sphinx.util.fileutil import copy_asset_file +if TYPE_CHECKING: + from ._types import SphinxExtensionMeta + SCRIPT_PATH = "_templates/api_redirect.js_t" diff --git a/docs/extensions/resourcelinks.py b/docs/extensions/resourcelinks.py index 76a57b4656..d93f6f2715 100644 --- a/docs/extensions/resourcelinks.py +++ b/docs/extensions/resourcelinks.py @@ -10,12 +10,13 @@ from sphinx.util.nodes import split_explicit_title if TYPE_CHECKING: - from _types import SphinxExtensionMeta from docutils.nodes import Node, system_message from docutils.parsers.rst.states import Inliner from sphinx.application import Sphinx from sphinx.util.typing import RoleFunction + from ._types import SphinxExtensionMeta + def make_link_role(resource_links: Dict[str, str]) -> RoleFunction: def role( 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 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/noxfile.py b/noxfile.py index 5ed870cb12..326ae81f6d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -24,8 +24,6 @@ "PDM_IGNORE_SAVED_PYTHON": "1", }, ) -# support the python parser in case the native parser isn't available -os.environ.setdefault("LIBCST_PARSER_TYPE", "native") nox.options.error_on_external_run = True @@ -204,7 +202,7 @@ def pyright(session: nox.Session) -> None: pass -@nox.session(python=["3.8", "3.9", "3.10", "3.11"]) +@nox.session(python=["3.8", "3.9", "3.10", "3.11", "3.12"]) @nox.parametrize( "extras", [ diff --git a/pyproject.toml b/pyproject.toml index 369abf8c04..73f3ad1b9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,30 +70,37 @@ 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.261", + "ruff==0.0.292", +] +changelog = [ + "towncrier==23.6.0", ] codemod = [ # run codemods on the respository (mostly automated typing) - "libcst~=0.4.9", + "libcst~=1.1.0", "black==23.9.1", "autotyping==23.2.0", ] typing = [ # this is not pyright itself, but the python wrapper - "pyright==1.1.291", - "typing-extensions~=4.5.0", + "pyright==1.1.336", + "typing-extensions~=4.8.0", # only used for type-checking, version does not matter "pytz", ] test = [ - "pytest~=7.2.1", + "pytest~=7.4.2", "pytest-cov~=4.0.0", "pytest-asyncio~=0.20.3", "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" } @@ -120,7 +127,7 @@ runner = "pdm run" [tool.black] line-length = 100 -target-version = ["py38", "py39", "py310", "py311"] +target-version = ["py38", "py39", "py310", "py311", "py312"] [tool.isort] profile = "black" @@ -149,7 +156,7 @@ select = [ # "RET", # flake8-return # "SIM", # flake8-simplify "TID251", # flake8-tidy-imports, replaces S404 - # "TCH", # flake8-type-checking + "TCH", # flake8-type-checking "RUF", # ruff specific exceptions "PT", # flake8-pytest-style "Q", # flake8-quotes @@ -177,19 +184,34 @@ ignore = [ "RUF005", # might not be actually faster "RUF006", # might not be an issue/very extreme cases + # calling subprocess with dynamic arguments is generally fine, the only way to avoid this is ignoring it + "S603", + + # partial executable paths (i.e. "git" instead of "/usr/bin/git") are fine + "S607", + # ignore try-except-pass. Bare excepts are caught with E722 "S110", # provide specific codes on type: ignore "PGH003", + # typevar names don't match variance (we don't always want this) + "PLC0105", + # import aliases are fixed by isort "PLC0414", - # outer loop variables are overwritten by inner assignment target, these are mostly intentional "PLW2901", + # ignore imports that could be moved into type-checking blocks + # (no real advantage other than possibly avoiding cycles, + # but can be dangerous in places where we need to parse signatures) + "TCH001", + "TCH002", + "TCH003", + # temporary disables, to fix later "D205", # blank line required between summary and description "D401", # first line of docstring should be in imperative mood @@ -215,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 @@ -363,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() 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/ext/commands/test_base_core.py b/tests/ext/commands/test_base_core.py index ccb15f42fc..734353c5a4 100644 --- a/tests/ext/commands/test_base_core.py +++ b/tests/ext/commands/test_base_core.py @@ -2,6 +2,7 @@ import pytest +import disnake from disnake import Permissions from disnake.ext import commands @@ -82,3 +83,20 @@ async def overwrite_decorator_below(self, _) -> None: assert Cog.overwrite_decorator_below.default_member_permissions == Permissions(64) assert Cog().overwrite_decorator_below.default_member_permissions == Permissions(64) + + +def test_localization_copy() -> None: + class Cog(commands.Cog): + @commands.slash_command() + async def cmd( + self, + inter, + param: int = commands.Param(name=disnake.Localized("param", key="PARAM")), + ) -> None: + ... + + # Ensure the command copy that happens on cog init doesn't raise a LocalizationWarning for the options. + cog = Cog() + + with pytest.warns(disnake.LocalizationWarning): + assert cog.get_slash_commands()[0].options[0].name_localizations.data is None diff --git a/tests/ext/commands/test_core.py b/tests/ext/commands/test_core.py index 1d3076a845..2b29f51988 100644 --- a/tests/ext/commands/test_core.py +++ b/tests/ext/commands/test_core.py @@ -1,20 +1,10 @@ # SPDX-License-Identifier: MIT -from typing import TYPE_CHECKING +from typing_extensions import assert_type from disnake.ext import commands from tests.helpers import reveal_type -if TYPE_CHECKING: - from typing_extensions import assert_type - - # NOTE: using undocumented `expected_text` parameter of pyright instead of `assert_type`, - # as `assert_type` can't handle bound ParamSpecs - reveal_type( - 42, # type: ignore - expected_text="str", # type: ignore - ) - class CustomContext(commands.Context): ... diff --git a/tests/ext/commands/test_params.py b/tests/ext/commands/test_params.py index a3b4ea4289..96f2c08c32 100644 --- a/tests/ext/commands/test_params.py +++ b/tests/ext/commands/test_params.py @@ -189,7 +189,6 @@ def test_string(self) -> None: assert info.max_value is None assert info.type == annotation.underlying_type - # uses lambdas since new union syntax isn't supported on all versions @pytest.mark.parametrize( "annotation_str", [ @@ -213,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/helpers.py b/tests/helpers.py index 2d5a4d8e41..8e22e0cd08 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -16,6 +16,15 @@ def reveal_type(*args, **kwargs) -> None: raise RuntimeError +if TYPE_CHECKING: + # NOTE: using undocumented `expected_text` parameter of pyright instead of `assert_type`, + # as `assert_type` can't handle bound ParamSpecs + reveal_type( + 42, # type: ignore # suppress "revealed type is ..." output + expected_text="str", # type: ignore # ensure the functionality we want still works as expected + ) + + CallableT = TypeVar("CallableT", bound=Callable) diff --git a/tests/test_utils.py b/tests/test_utils.py index 8c5ee4ec38..46237c2019 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,13 +2,14 @@ import asyncio import datetime +import functools import inspect import os import sys import warnings from dataclasses import dataclass from datetime import timedelta, timezone -from typing import Any, Dict, List, Literal, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, TypeVar, Union from unittest import mock import pytest @@ -17,7 +18,13 @@ import disnake from disnake import utils -from . import helpers +from . import helpers, utils_helper_module + +if TYPE_CHECKING: + from typing_extensions import TypeAliasType +elif sys.version_info >= (3, 12): + # non-3.12 tests shouldn't be using this + from typing import TypeAliasType def test_missing() -> None: @@ -377,7 +384,7 @@ class C(A): __slots__ = {"c": "uwu"} class D(B, C): - __slots__ = "xyz" + __slots__ = "xyz" # noqa: PLC0205 # this is intentional assert list(utils.get_slots(D)) == ["a", "a2", "c", "xyz"] @@ -758,8 +765,8 @@ def test_normalise_optional_params(params, expected) -> None: ("Tuple[dict, List[Literal[42, 99]]]", Tuple[dict, List[Literal[42, 99]]], True), # 3.10 union syntax pytest.param( - "int | Literal[False]", - Union[int, Literal[False]], + "int | float", + Union[int, float], True, marks=pytest.mark.skipif(sys.version_info < (3, 10), reason="syntax requires py3.10"), ), @@ -784,6 +791,65 @@ def test_resolve_annotation_literal() -> None: utils.resolve_annotation(Literal[timezone.utc, 3], globals(), locals(), {}) # type: ignore +@pytest.mark.skipif(sys.version_info < (3, 12), reason="syntax requires py3.12") +class TestResolveAnnotationTypeAliasType: + def test_simple(self) -> None: + # this is equivalent to `type CoolList = List[int]` + CoolList = TypeAliasType("CoolList", List[int]) + assert utils.resolve_annotation(CoolList, globals(), locals(), {}) == List[int] + + def test_generic(self) -> None: + # this is equivalent to `type CoolList[T] = List[T]; CoolList[int]` + T = TypeVar("T") + CoolList = TypeAliasType("CoolList", List[T], type_params=(T,)) + + annotation = CoolList[int] + assert utils.resolve_annotation(annotation, globals(), locals(), {}) == List[int] + + # alias and arg in local scope + def test_forwardref_local(self) -> None: + T = TypeVar("T") + IntOrStr = Union[int, str] + CoolList = TypeAliasType("CoolList", List[T], type_params=(T,)) + + annotation = CoolList["IntOrStr"] + assert utils.resolve_annotation(annotation, globals(), locals(), {}) == List[IntOrStr] + + # alias and arg in other module scope + def test_forwardref_module(self) -> None: + resolved = utils.resolve_annotation( + utils_helper_module.ListWithForwardRefAlias, globals(), locals(), {} + ) + assert resolved == List[Union[int, str]] + + # combination of the previous two, alias in other module scope and arg in local scope + def test_forwardref_mixed(self) -> None: + LocalIntOrStr = Union[int, str] + + annotation = utils_helper_module.GenericListAlias["LocalIntOrStr"] + assert utils.resolve_annotation(annotation, globals(), locals(), {}) == List[LocalIntOrStr] + + # two different forwardrefs with same name + def test_forwardref_duplicate(self) -> None: + DuplicateAlias = int + + # first, resolve an annotation where `DuplicateAlias` resolves to the local int + cache = {} + assert ( + utils.resolve_annotation(List["DuplicateAlias"], globals(), locals(), cache) + == List[int] + ) + + # then, resolve an annotation where the globalns changes and `DuplicateAlias` resolves to something else + # (i.e. this should not resolve to `List[int]` despite {"DuplicateAlias": int} in the cache) + assert ( + utils.resolve_annotation( + utils_helper_module.ListWithDuplicateAlias, globals(), locals(), cache + ) + == List[str] + ) + + @pytest.mark.parametrize( ("dt", "style", "expected"), [ @@ -880,3 +946,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 diff --git a/tests/ui/test_action_row.py b/tests/ui/test_action_row.py index 9e72ecc3eb..f9c40ffedc 100644 --- a/tests/ui/test_action_row.py +++ b/tests/ui/test_action_row.py @@ -4,17 +4,20 @@ from unittest import mock import pytest +from typing_extensions import assert_type import disnake -from disnake.ui import ActionRow, Button, StringSelect, TextInput, WrappedComponent +from disnake.ui import ( + ActionRow, + Button, + MessageUIComponent, + ModalUIComponent, + StringSelect, + TextInput, + WrappedComponent, +) from disnake.ui.action_row import components_to_dict, components_to_rows -if TYPE_CHECKING: - from typing_extensions import assert_type - - from disnake.ui import MessageUIComponent, ModalUIComponent - - button1 = Button() button2 = Button() button3 = Button() @@ -133,9 +136,8 @@ def test_with_components(self) -> None: row_msg = ActionRow.with_message_components() assert list(row_msg.children) == [] - if TYPE_CHECKING: - assert_type(row_modal, ActionRow[ModalUIComponent]) - assert_type(row_msg, ActionRow[MessageUIComponent]) + assert_type(row_modal, ActionRow[ModalUIComponent]) + assert_type(row_msg, ActionRow[MessageUIComponent]) def test_rows_from_message(self) -> None: rows = [ diff --git a/tests/ui/test_decorators.py b/tests/ui/test_decorators.py index 86ecd65ba9..e9c3680873 100644 --- a/tests/ui/test_decorators.py +++ b/tests/ui/test_decorators.py @@ -30,21 +30,21 @@ 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"} - # 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) diff --git a/tests/utils_helper_module.py b/tests/utils_helper_module.py new file mode 100644 index 0000000000..7711e861b8 --- /dev/null +++ b/tests/utils_helper_module.py @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: MIT + +"""Separate module file for some test_utils.py type annotation tests.""" + +import sys +from typing import TYPE_CHECKING, List, TypeVar, Union + +version = sys.version_info # assign to variable to trick pyright + +if TYPE_CHECKING: + from typing_extensions import TypeAliasType +elif version >= (3, 12): + # non-3.12 tests shouldn't be using this + from typing import TypeAliasType + +if version >= (3, 12): + CoolUniqueIntOrStrAlias = Union[int, str] + ListWithForwardRefAlias = TypeAliasType( + "ListWithForwardRefAlias", List["CoolUniqueIntOrStrAlias"] + ) + + T = TypeVar("T") + GenericListAlias = TypeAliasType("GenericListAlias", List[T], type_params=(T,)) + + DuplicateAlias = str + ListWithDuplicateAlias = TypeAliasType("ListWithDuplicateAlias", List["DuplicateAlias"])