diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000..16761a1db --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Replace Black with Ruff, then format whole project. +44a44e938fb2bd0bb085d8aa4577abeb01653ad3 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2cacf8f2b..73992a155 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,7 +27,7 @@ jobs: - name: Install dependencies id: install-deps run: | - python -m pip install --upgrade pip setuptools wheel ruff==0.12 requests + python -m pip install --upgrade pip setuptools wheel ruff==0.12 requests "typing_extensions>=4.3,<5" pip install -U -r requirements.txt - name: Setup node.js diff --git a/discord/enums.py b/discord/enums.py index 4fe07ffce..7dc4bccd0 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -488,7 +488,7 @@ class AuditLogAction(Enum): AuditLogAction.home_settings_update: AuditLogActionCategory.update, } # fmt: on - return lookup[self] + return lookup.get(self, None) @property def target_type(self) -> Optional[str]: diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index 57f9e741b..6ed0273e3 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -229,7 +229,7 @@ class Loop(Generic[LF]): # Sometimes asyncio is cheeky and wakes up a few microseconds before our target # time, causing it to repeat a run. while self._is_explicit_time() and self._next_iteration <= self._last_iteration: - _log.warn( + _log.warning( ( 'Clock drift detected for task %s. Woke up at %s but needed to sleep until %s. ' 'Sleeping until %s again to correct clock' @@ -249,7 +249,14 @@ class Loop(Generic[LF]): self._last_iteration_failed = True if not self.reconnect: raise - await asyncio.sleep(backoff.delay()) + + retry_after = backoff.delay() + _log.exception( + 'Handling exception in internal background task %s. Retrying in %.2fs', + self.coro.__qualname__, + retry_after, + ) + await asyncio.sleep(retry_after) else: if self._stop_next_iteration: return diff --git a/discord/message.py b/discord/message.py index db2e0e448..02f31198d 100644 --- a/discord/message.py +++ b/discord/message.py @@ -488,7 +488,7 @@ class MessageSnapshot: Extra features of the the message snapshot. stickers: List[:class:`StickerItem`] A list of sticker items given to the message. - components: List[Union[:class:`ActionRow`, :class:`Button`, :class:`SelectMenu`, :class:`Container`, :class:`SectionComponent`, :class:`TextDisplay`, :class:`MediaGalleryComponent`, :class:`FileComponent`, :class:`SeparatorComponent`, :class:`ThumbnailComponent`]] + components: List[:class:`Component`]] A list of components in the message. """ @@ -2099,7 +2099,7 @@ class Message(PartialMessage, Hashable): A list of sticker items given to the message. .. versionadded:: 1.6 - components: List[Union[:class:`ActionRow`, :class:`Button`, :class:`SelectMenu`]] + components: List[:class:`Component`] A list of components in the message. If :attr:`Intents.message_content` is not enabled this will always be an empty list unless the bot is mentioned or the message is a direct message. diff --git a/discord/permissions.py b/discord/permissions.py index 729763785..a1e0d21c2 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -88,6 +88,7 @@ if TYPE_CHECKING: use_soundboard: BoolOrNoneT use_external_sounds: BoolOrNoneT send_voice_messages: BoolOrNoneT + set_voice_channel_status: BoolOrNoneT create_expressions: BoolOrNoneT create_events: BoolOrNoneT send_polls: BoolOrNoneT @@ -252,7 +253,7 @@ class Permissions(BaseFlags): permissions set to ``True``. """ # Some of these are 0 because we don't want to set unnecessary bits - return cls(0b0000_0000_0000_1110_0111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111) + return cls(0b0000_0000_0000_1111_0111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111) @classmethod def _timeout_mask(cls) -> int: @@ -383,8 +384,12 @@ class Permissions(BaseFlags): @classmethod def voice(cls) -> Self: """A factory method that creates a :class:`Permissions` with all - "Voice" permissions from the official Discord UI set to ``True``.""" - return cls(0b0000_0000_0000_0000_0010_0100_1000_0000_0000_0011_1111_0000_0000_0011_0000_0000) + "Voice" permissions from the official Discord UI set to ``True``. + + .. versionchanged:: 2.7 + Added :attr:`set_voice_channel_status` permission. + """ + return cls(0b0000_0000_0000_0001_0010_0100_1000_0000_0000_0011_1111_0000_0000_0011_0000_0000) @classmethod def stage(cls) -> Self: @@ -839,6 +844,14 @@ class Permissions(BaseFlags): """ return 1 << 46 + @flag_value + def set_voice_channel_status(self) -> int: + """:class:`bool`: Returns ``True`` if a user can set voice channel status. + + .. versionadded:: 2.7 + """ + return 1 << 48 + @flag_value def send_polls(self) -> int: """:class:`bool`: Returns ``True`` if a user can send poll messages. @@ -989,6 +1002,7 @@ class PermissionOverwrite: use_soundboard: Optional[bool] use_external_sounds: Optional[bool] send_voice_messages: Optional[bool] + set_voice_channel_status: Optional[bool] create_expressions: Optional[bool] create_events: Optional[bool] send_polls: Optional[bool] diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 1cdf4b6f1..b0598db20 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -67,6 +67,7 @@ if TYPE_CHECKING: from ..components import SelectOption from ..interactions import Interaction from .container import Container + from .dynamic import DynamicItem SelectCallbackDecorator = Callable[[ItemCallbackType['S', BaseSelectT]], BaseSelectT] @@ -194,10 +195,19 @@ class ActionRow(Item[V]): # it should error anyways. return True + def _swap_item(self, base: Item, new: DynamicItem, custom_id: str) -> None: + child_index = self._children.index(base) + self._children[child_index] = new # type: ignore + @property def width(self): return 5 + @property + def _total_count(self) -> int: + # 1 for self and all children + return 1 + len(self._children) + @property def type(self) -> Literal[ComponentType.action_row]: return ComponentType.action_row @@ -348,6 +358,7 @@ class ActionRow(Item[V]): The function being decorated should have three parameters, ``self`` representing the :class:`discord.ui.ActionRow`, the :class:`discord.Interaction` you receive and the :class:`discord.ui.Button` being pressed. + .. note:: Buttons with a URL or a SKU cannot be created with this function. diff --git a/discord/ui/container.py b/discord/ui/container.py index 1dcdca6b2..43f3ec1ee 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -50,6 +50,7 @@ if TYPE_CHECKING: from ..components import Container as ContainerComponent from ..interactions import Interaction + from .dynamic import DynamicItem S = TypeVar('S', bound='Container', covariant=True) V = TypeVar('V', bound='LayoutView', covariant=True) @@ -198,6 +199,10 @@ class Container(Item[V]): def _has_children(self): return True + def _swap_item(self, base: Item, new: DynamicItem, custom_id: str) -> None: + child_index = self._children.index(base) + self._children[child_index] = new # type: ignore + @property def children(self) -> List[Item[V]]: """List[:class:`Item`]: The children of this container.""" @@ -229,6 +234,11 @@ class Container(Item[V]): def width(self): return 5 + @property + def _total_count(self) -> int: + # 1 for self and all children + return 1 + len(tuple(self.walk_children())) + def _is_v2(self) -> bool: return True @@ -308,10 +318,8 @@ class Container(Item[V]): if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__.__name__}') - if item._has_children() and self._view: - self._view._add_count(len(tuple(item.walk_children()))) # type: ignore - elif self._view: - self._view._add_count(1) + if self._view: + self._view._add_count(item._total_count) self._children.append(item) item._update_view(self.view) @@ -336,10 +344,7 @@ class Container(Item[V]): pass else: if self._view: - if item._has_children(): - self._view._add_count(-len(tuple(item.walk_children()))) # type: ignore - else: - self._view._add_count(-1) + self._view._add_count(-item._total_count) return self def find_item(self, id: int, /) -> Optional[Item[V]]: diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index fb38b4b2e..faed8c370 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -168,6 +168,10 @@ class DynamicItem(Generic[BaseT], Item[Union[View, LayoutView]]): def width(self) -> int: return self.item.width + @property + def _total_count(self) -> int: + return self.item._total_count + @classmethod async def from_custom_id( cls: Type[Self], interaction: Interaction[ClientT], item: Item[Any], match: re.Match[str], / diff --git a/discord/ui/item.py b/discord/ui/item.py index 9218d840d..5498dc20f 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -44,6 +44,7 @@ if TYPE_CHECKING: from ..components import Component from .action_row import ActionRow from .container import Container + from .dynamic import DynamicItem I = TypeVar('I', bound='Item[Any]') V = TypeVar('V', bound='BaseView', covariant=True) @@ -118,6 +119,9 @@ class Item(Generic[V]): return self._provided_custom_id return True + def _swap_item(self, base: Item, new: DynamicItem, custom_id: str) -> None: + raise ValueError + def __repr__(self) -> str: attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__item_repr_attributes__) return f'<{self.__class__.__name__} {attrs}>' @@ -143,6 +147,10 @@ class Item(Generic[V]): def width(self) -> int: return 1 + @property + def _total_count(self) -> int: + return 1 + @property def view(self) -> Optional[V]: """Optional[Union[:class:`View`, :class:`LayoutView`]]: The underlying view for this item.""" diff --git a/discord/ui/label.py b/discord/ui/label.py index 9357de425..7a2d496a6 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -35,7 +35,7 @@ if TYPE_CHECKING: from typing_extensions import Self from ..types.components import LabelComponent as LabelComponentPayload - from .view import View + from .view import BaseView # fmt: off @@ -44,7 +44,7 @@ __all__ = ( ) # fmt: on -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='BaseView', covariant=True) class Label(Item[V]): diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 3900b49c2..c85719a9b 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -33,8 +33,9 @@ from typing import TYPE_CHECKING, Any, Dict, Optional, Sequence, ClassVar, List from ..utils import MISSING, find from .._types import ClientT from .item import Item -from .view import View -from .label import Label +from .view import BaseView +from .select import BaseSelect +from .text_input import TextInput if TYPE_CHECKING: from typing_extensions import Self @@ -53,7 +54,7 @@ __all__ = ( _log = logging.getLogger(__name__) -class Modal(View): +class Modal(BaseView): """Represents a UI modal. This object must be inherited to create a modal popup window within discord. @@ -204,9 +205,7 @@ class Modal(View): children = sorted(self._children, key=key) components: List[Dict[str, Any]] = [] for child in children: - if isinstance(child, Label): - components.append(child.to_component_dict()) # type: ignore - else: + if isinstance(child, (BaseSelect, TextInput)): # Every implicit child wrapped in an ActionRow in a modal # has a single child of width 5 # It's also deprecated to use ActionRow in modals @@ -216,6 +215,8 @@ class Modal(View): 'components': [child.to_component_dict()], } ) + else: + components.append(child.to_component_dict()) return components @@ -234,3 +235,8 @@ class Modal(View): } return payload + + def add_item(self, item: Item[Any]) -> Self: + if len(self._children) >= 5: + raise ValueError('maximum number of children exceeded (5)') + return super().add_item(item) diff --git a/discord/ui/section.py b/discord/ui/section.py index c6eaeabc7..6a4026e22 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -29,12 +29,13 @@ from typing import TYPE_CHECKING, Any, Dict, Generator, List, Literal, Optional, from .item import Item from .text_display import TextDisplay from ..enums import ComponentType -from ..utils import MISSING, get as _utils_get +from ..utils import get as _utils_get if TYPE_CHECKING: from typing_extensions import Self from .view import LayoutView + from .dynamic import DynamicItem from ..components import SectionComponent V = TypeVar('V', bound='LayoutView', covariant=True) @@ -57,11 +58,6 @@ class Section(Item[V]): The section accessory. id: Optional[:class:`int`] The ID of this component. This must be unique across the view. - - Attributes - ---------- - accessory: :class:`Item` - The section accessory. """ __item_repr_attributes__ = ( @@ -72,7 +68,7 @@ class Section(Item[V]): __slots__ = ( '_children', - 'accessory', + '_accessory', ) def __init__( @@ -83,13 +79,11 @@ class Section(Item[V]): ) -> None: super().__init__() self._children: List[Item[V]] = [] - if children: - if len(children) > 3: - raise ValueError('maximum number of children exceeded') - self._children.extend( - [c if isinstance(c, Item) else TextDisplay(c) for c in children], - ) - self.accessory: Item[V] = accessory + for child in children: + self.add_item(child) + + accessory._parent = self + self._accessory: Item[V] = accessory self.id = id def __repr__(self) -> str: @@ -108,9 +102,31 @@ class Section(Item[V]): def width(self): return 5 + @property + def _total_count(self) -> int: + # Count the accessory, ourselves, and all children + return 2 + len(self._children) + + @property + def accessory(self) -> Item[V]: + """:class:`Item`: The section's accessory.""" + return self._accessory + + @accessory.setter + def accessory(self, value: Item[V]) -> None: + if not isinstance(value, Item): + raise TypeError(f'Expected an Item, got {value.__class__.__name__!r} instead') + + value._parent = self + self._accessory = value + def _is_v2(self) -> bool: return True + def _swap_item(self, base: Item, new: DynamicItem, custom_id: str) -> None: + if self.accessory.is_dispatchable() and getattr(self.accessory, 'custom_id', None) == custom_id: + self.accessory = new # type: ignore + def walk_children(self) -> Generator[Item[V], None, None]: """An iterator that recursively walks through all the children of this section and its children, if applicable. This includes the `accessory`. @@ -235,9 +251,8 @@ class Section(Item[V]): def from_component(cls, component: SectionComponent) -> Self: from .view import _component_to_item - # using MISSING as accessory so we can create the new one with the parent set - self = cls(id=component.id, accessory=MISSING) - self.accessory = _component_to_item(component.accessory, self) + accessory = _component_to_item(component.accessory, None) + self = cls(id=component.id, accessory=accessory) self.id = component.id self._children = [_component_to_item(c, self) for c in component.children] diff --git a/discord/ui/text_input.py b/discord/ui/text_input.py index 288e5efdc..de0c8e079 100644 --- a/discord/ui/text_input.py +++ b/discord/ui/text_input.py @@ -37,7 +37,7 @@ if TYPE_CHECKING: from ..types.components import TextInput as TextInputPayload from ..types.interactions import ModalSubmitTextInputInteractionData as ModalSubmitTextInputInteractionDataPayload - from .view import View + from .view import BaseView from ..interactions import Interaction @@ -47,7 +47,7 @@ __all__ = ( ) # fmt: on -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='BaseView', covariant=True) class TextInput(Item[V]): diff --git a/discord/ui/view.py b/discord/ui/view.py index 01f8543c6..cbf40a14d 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -300,6 +300,12 @@ class BaseView: if self.__timeout: self.__timeout_expiry = time.monotonic() + self.__timeout + def _swap_item(self, base: Item, new: DynamicItem, custom_id: str) -> None: + # if an error is raised it is catched by the try/except block that calls + # this function + child_index = self._children.index(base) + self._children[child_index] = new # type: ignore + @property def timeout(self) -> Optional[float]: """Optional[:class:`float`]: The timeout in seconds from last interaction with the UI before no longer accepting input. @@ -329,7 +335,9 @@ class BaseView: @property def total_children_count(self) -> int: - """:class:`int`: The total number of children in this view, including those from nested items.""" + """:class:`int`: The total number of children in this view, including those from nested items. + + .. versionadded:: 2.6""" return self._total_children @classmethod @@ -422,12 +430,7 @@ class BaseView: raise ValueError('v2 items cannot be added to this view') item._update_view(self) - added = 1 - - if item._has_children(): - added += len(tuple(item.walk_children())) # type: ignore - - self._add_count(added) + self._add_count(item._total_count) self._children.append(item) return self @@ -448,10 +451,7 @@ class BaseView: except ValueError: pass else: - removed = 1 - if item._has_children(): - removed += len(tuple(item.walk_children())) # type: ignore - self._add_count(-removed) + self._add_count(-item._total_count) return self @@ -657,6 +657,8 @@ class BaseView: """An iterator that recursively walks through all the children of this view and its children, if applicable. + .. versionadded:: 2.6 + Yields ------ :class:`Item` @@ -954,11 +956,9 @@ class ViewStore: parent = base_item._parent or view try: - child_index = parent._children.index(base_item) # type: ignore + parent._swap_item(base_item, item, custom_id) except ValueError: return - else: - parent._children[child_index] = item # type: ignore item._view = view item._rendered_row = base_item._rendered_row diff --git a/docs/api.rst b/docs/api.rst index 7a75ba258..53ddec06e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6091,23 +6091,6 @@ PollMedia .. autoclass:: PollMedia :members: -UnfurledMediaItem -~~~~~~~~~~~~~~~~~ - -.. attributetable:: UnfurledMediaItem - -.. autoclass:: UnfurledMediaItem - :members: - - -MediaGalleryItem -~~~~~~~~~~~~~~~~ - -.. attributetable:: MediaGalleryItem - -.. autoclass:: MediaGalleryItem - :members: - Exceptions ------------ diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 1feeca879..b2098128b 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -303,6 +303,24 @@ Choice :members: +UnfurledMediaItem +~~~~~~~~~~~~~~~~~ + +.. attributetable:: UnfurledMediaItem + +.. autoclass:: UnfurledMediaItem + :members: + + +MediaGalleryItem +~~~~~~~~~~~~~~~~ + +.. attributetable:: MediaGalleryItem + +.. autoclass:: MediaGalleryItem + :members: + + Enumerations ------------- diff --git a/docs/whats_new.rst b/docs/whats_new.rst index c5c7fa2a9..25011bb4b 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -11,6 +11,21 @@ Changelog This page keeps a detailed human friendly rendering of what's new and changed in specific versions. +.. _vp2p6p1: + +v2.6.1 +------- + +Bug Fixes +~~~~~~~~~~ + +- Fix :attr:`ui.Section.children` and :attr:`ui.Section.accessory` having ``None`` as the :attr:`Item.parent` (:issue:`10269`) +- Fix error when using a :class:`ui.DynamicItem` inside an :class:`ui.Section` +- Fix :class:`ui.DynamicItem` not working when set as an :attr:`ui.Section.acessory` (:issue:`10271`) +- Fix :attr:`ui.LayoutView.total_children_count` being inaccurate when adding nested items +- Fix crash when accessing :attr:`AuditLogEntry.category` for unknown audit log actions +- |tasks| Add logging statement when a handled exception occurs (:issue:`10276`) + .. _vp2p6p0: v2.6.0