diff --git a/CHANGELOG.md b/CHANGELOG.md index 4712e2edbe..e980d7228d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,33 @@ possible (see our [Version Guarantees] for more info). These changes are available on the `master` branch, but have not yet been released. +## [2.6.1] - 2024-09-15 + +### Fixed + +- Fixed premature garbage collection of tasks. + ([#2510](https://github.com/Pycord-Development/pycord/pull/2510)) +- Fixed `EntitlementIterator` type hints and behavior with `limit > 100`. + ([#2555](https://github.com/Pycord-Development/pycord/pull/2555)) +- Fixed missing `stacklevel` parameter in `warn_deprecated` function call inside + `@utils.deprecated`. ([#2500](https://github.com/Pycord-Development/pycord/pull/2500)) +- Fixed the type hint in `ConnectionState._polls` to reflect actual behavior, changing it + from `Guild` to `Poll`. + ([#2500](https://github.com/Pycord-Development/pycord/pull/2500)) +- Fixed missing `__slots__` attributes in `RawReactionClearEmojiEvent` and + `RawMessagePollVoteEvent`. + ([#2500](https://github.com/Pycord-Development/pycord/pull/2500)) +- Fixed the type of `ForumChannel.default_sort_order`, changing it from `int` to + `SortOrder`. ([#2500](https://github.com/Pycord-Development/pycord/pull/2500)) +- Fixed `PartialMessage` causing errors when created from `PartialMessageable`. + ([#2568](https://github.com/Pycord-Development/pycord/pull/2500)) +- Fixed the `guild` attribute of `Member`s recieved from a `UserCommand` being `None`. + ([#2573](https://github.com/Pycord-Development/pycord/pull/2573)) +- Fixed `Webhook.send` not including attachment data. + ([#2513](https://github.com/Pycord-Development/pycord/pull/2513)) +- Fixed inverted type hints in `CheckAnyFailure`. + ([#2502](https://github.com/Pycord-Development/pycord/pull/2502)) + ## [2.6.0] - 2024-07-09 ### Added diff --git a/discord/channel.py b/discord/channel.py index 7608e1757d..27230a380f 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -1036,6 +1036,9 @@ def _update(self, guild: Guild, data: ForumChannelPayload) -> None: for tag in (data.get("available_tags") or []) ] self.default_sort_order: SortOrder | None = data.get("default_sort_order", None) + if self.default_sort_order is not None: + self.default_sort_order = try_enum(SortOrder, self.default_sort_order) + reaction_emoji_ctx: dict = data.get("default_reaction_emoji") if reaction_emoji_ctx is not None: emoji_name = reaction_emoji_ctx.get("emoji_name") diff --git a/discord/client.py b/discord/client.py index 4cfaef59cf..1fc004f35b 100644 --- a/discord/client.py +++ b/discord/client.py @@ -256,6 +256,9 @@ def __init__( VoiceClient.warn_nacl = False _log.warning("PyNaCl is not installed, voice will NOT be supported") + # Used to hard-reference tasks so they don't get garbage collected (discarded with done_callbacks) + self._tasks = set() + async def __aenter__(self) -> Client: loop = asyncio.get_running_loop() self.loop = loop @@ -423,8 +426,12 @@ def _schedule_event( **kwargs: Any, ) -> asyncio.Task: wrapped = self._run_event(coro, event_name, *args, **kwargs) - # Schedules the task - return asyncio.create_task(wrapped, name=f"pycord: {event_name}") + + # Schedule task and store in set to avoid task garbage collection + task = asyncio.create_task(wrapped, name=f"pycord: {event_name}") + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + return task def dispatch(self, event: str, *args: Any, **kwargs: Any) -> None: _log.debug("Dispatching event %s", event) diff --git a/discord/commands/core.py b/discord/commands/core.py index c1093351d9..d25329eb97 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -1786,12 +1786,8 @@ async def _invoke(self, ctx: ApplicationContext) -> None: v["id"] = int(i) user = v member["user"] = user - target = Member( - data=member, - guild=ctx.interaction._state._get_guild(ctx.interaction.guild_id), - state=ctx.interaction._state, - ) - + cache_flag = ctx.interaction._state.member_cache_flags.interaction + target = ctx.guild._get_and_update_member(member, user["id"], cache_flag) if self.cog is not None: await self.callback(self.cog, ctx, target) else: diff --git a/discord/commands/options.py b/discord/commands/options.py index 9c56e3aeee..721a03ffe3 100644 --- a/discord/commands/options.py +++ b/discord/commands/options.py @@ -424,7 +424,7 @@ def option(name, input_type=None, **kwargs): Attributes ---------- parameter_name: :class:`str` - The name of the target parameter this option is mapped to. + The name of the target function parameter this option is mapped to. This allows you to have a separate UI ``name`` and parameter name. """ diff --git a/discord/ext/bridge/core.py b/discord/ext/bridge/core.py index 384e9ab37a..103f345693 100644 --- a/discord/ext/bridge/core.py +++ b/discord/ext/bridge/core.py @@ -660,7 +660,7 @@ def bridge_option(name, input_type=None, **kwargs): Attributes ---------- parameter_name: :class:`str` - The name of the target parameter this option is mapped to. + The name of the target function parameter this option is mapped to. This allows you to have a separate UI ``name`` and parameter name. """ diff --git a/discord/ext/commands/errors.py b/discord/ext/commands/errors.py index c9174054d8..c768fd7c00 100644 --- a/discord/ext/commands/errors.py +++ b/discord/ext/commands/errors.py @@ -212,10 +212,10 @@ class CheckAnyFailure(CheckFailure): """ def __init__( - self, checks: list[CheckFailure], errors: list[Callable[[Context], bool]] + self, checks: list[Callable[[Context], bool]], errors: list[CheckFailure] ) -> None: - self.checks: list[CheckFailure] = checks - self.errors: list[Callable[[Context], bool]] = errors + self.checks: list[Callable[[Context], bool]] = checks + self.errors: list[CheckFailure] = errors super().__init__("You do not have permission to run this command.") diff --git a/discord/http.py b/discord/http.py index 61bd0cc38a..9eb0101697 100644 --- a/discord/http.py +++ b/discord/http.py @@ -2610,35 +2610,6 @@ def bulk_upsert_guild_commands( ) return self.request(r, json=payload) - # Application commands (permissions) - - def get_command_permissions( - self, - application_id: Snowflake, - guild_id: Snowflake, - command_id: Snowflake, - ) -> Response[interactions.GuildApplicationCommandPermissions]: - r = Route( - "GET", - "/applications/{application_id}/guilds/{guild_id}/commands/{command_id}/permissions", - application_id=application_id, - guild_id=guild_id, - ) - return self.request(r) - - def get_guild_command_permissions( - self, - application_id: Snowflake, - guild_id: Snowflake, - ) -> Response[list[interactions.GuildApplicationCommandPermissions]]: - r = Route( - "GET", - "/applications/{application_id}/guilds/{guild_id}/commands/permissions", - application_id=application_id, - guild_id=guild_id, - ) - return self.request(r) - # Guild Automod Rules def get_auto_moderation_rules( @@ -2876,6 +2847,8 @@ def delete_followup_message( ) return self.request(r) + # Application commands (permissions) + def get_guild_application_command_permissions( self, application_id: Snowflake, diff --git a/discord/iterators.py b/discord/iterators.py index 7404e790a4..13f67266ea 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -64,6 +64,7 @@ from .types.audit_log import AuditLog as AuditLogPayload from .types.guild import Guild as GuildPayload from .types.message import Message as MessagePayload + from .types.monetization import Entitlement as EntitlementPayload from .types.threads import Thread as ThreadPayload from .types.user import PartialUser as PartialUserPayload from .user import User @@ -988,11 +989,21 @@ def __init__( self.guild_id = guild_id self.exclude_ended = exclude_ended + self._filter = None + + if self.before and self.after: + self._retrieve_entitlements = self._retrieve_entitlements_before_strategy + self._filter = lambda e: int(e["id"]) > self.after.id + elif self.after: + self._retrieve_entitlements = self._retrieve_entitlements_after_strategy + else: + self._retrieve_entitlements = self._retrieve_entitlements_before_strategy + self.state = state self.get_entitlements = state.http.list_entitlements self.entitlements = asyncio.Queue() - async def next(self) -> BanEntry: + async def next(self) -> Entitlement: if self.entitlements.empty(): await self.fill_entitlements() @@ -1014,30 +1025,57 @@ async def fill_entitlements(self): if not self._get_retrieve(): return + data = await self._retrieve_entitlements(self.retrieve) + + if self._filter: + data = list(filter(self._filter, data)) + + if len(data) < 100: + self.limit = 0 # terminate loop + + for element in data: + await self.entitlements.put(Entitlement(data=element, state=self.state)) + + async def _retrieve_entitlements(self, retrieve) -> list[Entitlement]: + """Retrieve entitlements and update next parameters.""" + raise NotImplementedError + + async def _retrieve_entitlements_before_strategy( + self, retrieve: int + ) -> list[EntitlementPayload]: + """Retrieve entitlements using before parameter.""" before = self.before.id if self.before else None - after = self.after.id if self.after else None data = await self.get_entitlements( self.state.application_id, before=before, - after=after, - limit=self.retrieve, + limit=retrieve, user_id=self.user_id, guild_id=self.guild_id, sku_ids=self.sku_ids, exclude_ended=self.exclude_ended, ) + if data: + if self.limit is not None: + self.limit -= retrieve + self.before = Object(id=int(data[-1]["id"])) + return data - if not data: - # no data, terminate - return - - if self.limit: - self.limit -= self.retrieve - - if len(data) < 100: - self.limit = 0 # terminate loop - - self.after = Object(id=int(data[-1]["id"])) - - for element in reversed(data): - await self.entitlements.put(Entitlement(data=element, state=self.state)) + async def _retrieve_entitlements_after_strategy( + self, retrieve: int + ) -> list[EntitlementPayload]: + """Retrieve entitlements using after parameter.""" + after = self.after.id if self.after else None + data = await self.get_entitlements( + self.state.application_id, + after=after, + limit=retrieve, + user_id=self.user_id, + guild_id=self.guild_id, + sku_ids=self.sku_ids, + exclude_ended=self.exclude_ended, + ) + if data: + if self.limit is not None: + self.limit -= retrieve + self.after = Object(id=int(data[-1]["id"])) + return data diff --git a/discord/message.py b/discord/message.py index 37ffd86f53..56d1cec968 100644 --- a/discord/message.py +++ b/discord/message.py @@ -42,6 +42,7 @@ from urllib.parse import parse_qs, urlparse from . import utils +from .channel import PartialMessageable from .components import _component_factory from .embeds import Embed from .emoji import Emoji @@ -2001,6 +2002,7 @@ class PartialMessage(Hashable): - :meth:`DMChannel.get_partial_message` - :meth:`VoiceChannel.get_partial_message` - :meth:`StageChannel.get_partial_message` + - :meth:`PartialMessageable.get_partial_message` Note that this class is trimmed down and has no rich attributes. @@ -2022,7 +2024,7 @@ class PartialMessage(Hashable): Attributes ---------- - channel: Union[:class:`TextChannel`, :class:`Thread`, :class:`DMChannel`, :class:`VoiceChannel`, :class:`StageChannel`] + channel: Union[:class:`TextChannel`, :class:`Thread`, :class:`DMChannel`, :class:`VoiceChannel`, :class:`StageChannel`, :class:`PartialMessageable`] The channel associated with this partial message. id: :class:`int` The message ID. @@ -2053,9 +2055,9 @@ def __init__(self, *, channel: PartialMessageableChannel, id: int): ChannelType.news_thread, ChannelType.public_thread, ChannelType.private_thread, - ): + ) and not isinstance(channel, PartialMessageable): raise TypeError( - "Expected TextChannel, VoiceChannel, StageChannel, DMChannel or Thread not" + "Expected TextChannel, VoiceChannel, StageChannel, DMChannel, Thread or PartialMessageable not" f" {type(channel)!r}" ) diff --git a/discord/raw_models.py b/discord/raw_models.py index e59507dd48..a2881839a6 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -327,7 +327,17 @@ class RawReactionClearEmojiEvent(_RawReprMixin): .. versionadded:: 2.5 """ - __slots__ = ("message_id", "channel_id", "guild_id", "emoji", "burst", "data") + __slots__ = ( + "message_id", + "channel_id", + "guild_id", + "emoji", + "burst", + "data", + "burst_colours", + "burst_colors", + "type", + ) def __init__(self, data: ReactionClearEmojiEvent, emoji: PartialEmoji) -> None: self.emoji: PartialEmoji = emoji @@ -807,7 +817,15 @@ class RawMessagePollVoteEvent(_RawReprMixin): The raw data sent by the `gateway ` """ - __slots__ = ("user_id", "message_id", "channel_id", "guild_id", "data", "added") + __slots__ = ( + "user_id", + "message_id", + "answer_id", + "channel_id", + "guild_id", + "data", + "added", + ) def __init__(self, data: MessagePollVoteEvent, added: bool) -> None: self.user_id: int = int(data["user_id"]) diff --git a/discord/state.py b/discord/state.py index c8d8d4dced..4170d33fef 100644 --- a/discord/state.py +++ b/discord/state.py @@ -276,7 +276,7 @@ def clear(self, *, views: bool = True) -> None: self._emojis: dict[int, Emoji] = {} self._stickers: dict[int, GuildSticker] = {} self._guilds: dict[int, Guild] = {} - self._polls: dict[int, Guild] = {} + self._polls: dict[int, Poll] = {} if views: self._view_store: ViewStore = ViewStore(self) self._modal_store: ModalStore = ModalStore(self) diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index 115f9708af..dd7438be21 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -36,7 +36,7 @@ class InputText: The maximum number of characters that can be entered. Must be between 1 and 4000. required: Optional[:class:`bool`] - Whether the input text field is required or not. Defaults to `True`. + Whether the input text field is required or not. Defaults to ``True``. value: Optional[:class:`str`] Pre-fills the input text field with this value. Must be 4000 characters or fewer. @@ -151,7 +151,7 @@ def placeholder(self, value: str | None): @property def min_length(self) -> int | None: - """The minimum number of characters that must be entered. Defaults to `0`.""" + """The minimum number of characters that must be entered. Defaults to 0.""" return self._underlying.min_length @min_length.setter @@ -177,7 +177,7 @@ def max_length(self, value: int | None): @property def required(self) -> bool | None: - """Whether the input text field is required or not. Defaults to `True`.""" + """Whether the input text field is required or not. Defaults to ``True``.""" return self._underlying.required @required.setter diff --git a/discord/utils.py b/discord/utils.py index b95678a1c4..9f01f53e71 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -377,6 +377,7 @@ def decorated(*args: P.args, **kwargs: P.kwargs) -> T: since=since, removed=removed, reference=reference, + stacklevel=stacklevel, ) return func(*args, **kwargs) diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 6d8f7b35c5..090f7c96f2 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -644,8 +644,9 @@ def handle_message_parameters( payload["embeds"] = [] if embed is None else [embed.to_dict()] if content is not MISSING: payload["content"] = str(content) if content is not None else None + _attachments = [] if attachments is not MISSING: - payload["attachments"] = [a.to_dict() for a in attachments] + _attachments = [a.to_dict() for a in attachments] if view is not MISSING: payload["components"] = view.to_components() if view is not None else [] @@ -674,32 +675,35 @@ def handle_message_parameters( payload["allowed_mentions"] = previous_allowed_mentions.to_dict() multipart = [] + multipart_files = [] if file is not MISSING: files = [file] if files: - multipart.append({"name": "payload_json", "value": utils._to_json(payload)}) - payload = None - if len(files) == 1: - file = files[0] - multipart.append( + for index, file in enumerate(files): + multipart_files.append( { - "name": "file", + "name": f"files[{index}]", "value": file.fp, "filename": file.filename, "content_type": "application/octet-stream", } ) - else: - for index, file in enumerate(files): - multipart.append( - { - "name": f"file{index}", - "value": file.fp, - "filename": file.filename, - "content_type": "application/octet-stream", - } - ) + _attachments.append( + { + "id": index, + "filename": file.filename, + "description": file.description, + } + ) + + if _attachments: + payload["attachments"] = _attachments + + if multipart_files: + multipart.append({"name": "payload_json", "value": utils._to_json(payload)}) + payload = None + multipart += multipart_files return ExecuteWebhookParameters(payload=payload, multipart=multipart, files=files) diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 3f8e8f6e5f..c789a25e6c 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -418,13 +418,13 @@ of :class:`enum.Enum`. Represents a slash command interaction. .. attribute:: component - Represents a component based interaction, i.e. using the Discord Bot UI Kit. + Represents a component-based interaction, i.e. using the Discord Bot UI Kit. .. attribute:: auto_complete Represents a autocomplete interaction for slash commands. .. attribute:: modal_submit - Represents a modal based interaction. + Represents a modal-based interaction. .. class:: InteractionResponseType diff --git a/docs/ext/bridge/api.rst b/docs/ext/bridge/api.rst index 75dfdb51de..66dbc56125 100644 --- a/docs/ext/bridge/api.rst +++ b/docs/ext/bridge/api.rst @@ -154,7 +154,7 @@ BridgeContext Subclasses .. attributetable:: discord.ext.bridge.Context -.. data:: discord.ext.bridge.Context +.. autoclass:: discord.ext.bridge.Context Alias of :data:`typing.Union` [ :class:`.BridgeExtContext`, :class:`.BridgeApplicationContext` ] for typing convenience.