From a7cf03216642c8aa430a24b3587bcc735ef78ed2 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Thu, 9 Jan 2025 13:52:04 +0100 Subject: [PATCH 001/104] feat(flags): add new message flag --- disnake/flags.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/disnake/flags.py b/disnake/flags.py index c8434bc14a..00ea89ac80 100644 --- a/disnake/flags.py +++ b/disnake/flags.py @@ -601,6 +601,7 @@ def __init__( failed_to_mention_roles_in_thread: bool = ..., has_snapshot: bool = ..., has_thread: bool = ..., + is_components_v2: bool = ..., is_crossposted: bool = ..., is_voice_message: bool = ..., loading: bool = ..., @@ -701,6 +702,17 @@ def has_snapshot(self): """ return 1 << 14 + @flag_value + def is_components_v2(self): + """:class:`bool`: Returns ``True`` if the message uses the Components V2 system. + + Messages with this flag will use specific components for content layout, + instead of :attr:`~Message.content` and :attr:`~Message.embeds`. + + .. versionadded:: 2.11 + """ + return 1 << 15 + class PublicUserFlags(BaseFlags): """Wraps up the Discord User Public flags. From 9becf08d48bc82830046e4db275b7ffab0d755dc Mon Sep 17 00:00:00 2001 From: shiftinv Date: Thu, 9 Jan 2025 13:55:41 +0100 Subject: [PATCH 002/104] feat(enums): add new component types --- disnake/enums.py | 35 +++++++++++++++++++++++++++++++++++ disnake/types/components.py | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/disnake/enums.py b/disnake/enums.py index d02b551d56..b75c0eb213 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -1208,6 +1208,41 @@ class ComponentType(Enum): .. versionadded:: 2.7 """ + section = 9 + """Represents a Components V2 section component. + + .. versionadded:: 2.11 + """ + text_display = 10 + """Represents a Components V2 text display component. + + .. versionadded:: 2.11 + """ + thumbnail = 11 + """Represents a Components V2 thumbnail component. + + .. versionadded:: 2.11 + """ + media_gallery = 12 + """Represents a Components V2 media gallery component. + + .. versionadded:: 2.11 + """ + file = 13 + """Represents a Components V2 file component. + + .. versionadded:: 2.11 + """ + separator = 14 + """Represents a Components V2 separator component. + + .. versionadded:: 2.11 + """ + container = 17 + """Represents a Components V2 container component. + + .. versionadded:: 2.11 + """ def __int__(self) -> int: return self.value diff --git a/disnake/types/components.py b/disnake/types/components.py index a4eef9f1bf..cefa002e2e 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -10,7 +10,7 @@ from .emoji import PartialEmoji from .snowflake import Snowflake -ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8] +ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextInputStyle = Literal[1, 2] From 215f37f257876a2737ba0401ff6507bfec658335 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Thu, 9 Jan 2025 14:32:49 +0100 Subject: [PATCH 003/104] feat(types): add `id` field to all component types --- disnake/types/components.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/disnake/types/components.py b/disnake/types/components.py index cefa002e2e..0b2d0c3b14 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -19,12 +19,17 @@ Component = Union["ActionRow", "ButtonComponent", "AnySelectMenu", "TextInput"] -class ActionRow(TypedDict): +class _BaseComponent(TypedDict): + # type: ComponentType # FIXME: current version of pyright complains about overriding types, latest might be fine + id: NotRequired[int] # NOTE: not implemented (yet?) + + +class ActionRow(_BaseComponent): type: Literal[1] components: List[Component] -class ButtonComponent(TypedDict): +class ButtonComponent(_BaseComponent): type: Literal[2] style: ButtonStyle label: NotRequired[str] @@ -48,7 +53,7 @@ class SelectDefaultValue(TypedDict): type: SelectDefaultValueType -class _SelectMenu(TypedDict): +class _SelectMenu(_BaseComponent): custom_id: str placeholder: NotRequired[str] min_values: NotRequired[int] @@ -99,7 +104,7 @@ class Modal(TypedDict): components: List[ActionRow] -class TextInput(TypedDict): +class TextInput(_BaseComponent): type: Literal[4] custom_id: str style: TextInputStyle From 91325ede1ef3522b6dd137df2ae756133723a9fb Mon Sep 17 00:00:00 2001 From: shiftinv Date: Thu, 9 Jan 2025 14:53:54 +0100 Subject: [PATCH 004/104] feat(types): add typeddicts for new components --- disnake/types/components.py | 95 ++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/disnake/types/components.py b/disnake/types/components.py index 0b2d0c3b14..4ffdea0a6f 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -13,14 +13,31 @@ ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextInputStyle = Literal[1, 2] +SeparatorSpacing = Literal[1, 2] SelectDefaultValueType = Literal["user", "role", "channel"] -Component = Union["ActionRow", "ButtonComponent", "AnySelectMenu", "TextInput"] +Component = Union[ + "ActionRow", + "ButtonComponent", + "AnySelectMenu", + "TextInput", + "SectionComponent", + "TextDisplayComponent", + "ThumbnailComponent", # TODO: reconsider the semantics of this `Component` union, not all of these types can appear in all places + "MediaGalleryComponent", + "SeparatorComponent", + "FileComponent", + "ContainerComponent", +] + + +# base types class _BaseComponent(TypedDict): # type: ComponentType # FIXME: current version of pyright complains about overriding types, latest might be fine + # TODO: always present in responses? id: NotRequired[int] # NOTE: not implemented (yet?) @@ -29,6 +46,9 @@ class ActionRow(_BaseComponent): components: List[Component] +# button + + class ButtonComponent(_BaseComponent): type: Literal[2] style: ButtonStyle @@ -40,6 +60,9 @@ class ButtonComponent(_BaseComponent): sku_id: NotRequired[Snowflake] +# selects + + class SelectOption(TypedDict): label: str value: str @@ -98,6 +121,9 @@ class ChannelSelectMenu(_SelectMenu): ] +# modal + + class Modal(TypedDict): title: str custom_id: str @@ -114,3 +140,70 @@ class TextInput(_BaseComponent): required: NotRequired[bool] value: NotRequired[str] placeholder: NotRequired[str] + + +# components v2 +# NOTE: these are type definitions for *sending*, while *receiving* likely has fewer optional fields + + +# TODO: this likely expands to an `EmbedImage`-like structure +class UnfurledMediaItem(TypedDict): + url: str + + +# XXX: drop `Component` suffix? `ButtonComponent` also uses it, selects don't. +class SectionComponent(_BaseComponent): + type: Literal[9] + components: List[TextDisplayComponent] + accessory: Component + + +class TextDisplayComponent(_BaseComponent): + type: Literal[10] + content: str + + +class ThumbnailComponent(_BaseComponent): + type: Literal[11] + media: UnfurledMediaItem + description: NotRequired[str] + spoiler: NotRequired[bool] + + +class MediaGalleryItem(TypedDict): + media: UnfurledMediaItem + description: NotRequired[str] + spoiler: NotRequired[bool] + + +class MediaGalleryComponent(_BaseComponent): + type: Literal[12] + items: List[MediaGalleryItem] + + +class FileComponent(_BaseComponent): + type: Literal[13] + file: UnfurledMediaItem # only supports `attachment://` urls + spoiler: NotRequired[bool] + + +class SeparatorComponent(_BaseComponent): + type: Literal[14] + divider: NotRequired[bool] + spacing: NotRequired[SeparatorSpacing] + + +class ContainerComponent(_BaseComponent): + type: Literal[17] + accent_color: NotRequired[int] + spoiler: NotRequired[bool] + components: List[ + Union[ + ActionRow, + TextDisplayComponent, + SectionComponent, + MediaGalleryComponent, + FileComponent, + SeparatorComponent, + ] + ] From c5084d86008b536d4edd4420d0e39fb4f4224c72 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Thu, 9 Jan 2025 16:18:57 +0100 Subject: [PATCH 005/104] chore: add some comments from testing --- disnake/types/components.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/disnake/types/components.py b/disnake/types/components.py index 4ffdea0a6f..668c3491aa 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -146,7 +146,7 @@ class TextInput(_BaseComponent): # NOTE: these are type definitions for *sending*, while *receiving* likely has fewer optional fields -# TODO: this likely expands to an `EmbedImage`-like structure +# TODO: this expands to an `EmbedImage`-like structure in responses, with more than just the `url` field class UnfurledMediaItem(TypedDict): url: str @@ -155,6 +155,8 @@ class UnfurledMediaItem(TypedDict): class SectionComponent(_BaseComponent): type: Literal[9] components: List[TextDisplayComponent] + # this currently only supports ThumbnailComponent, others will be added in the future + # (the API seemingly also allows buttons, but they don't render yet) accessory: Component @@ -163,9 +165,11 @@ class TextDisplayComponent(_BaseComponent): content: str +# note, can't be used at top level, appears to be exclusively for `SectionComponent.accessory`? class ThumbnailComponent(_BaseComponent): type: Literal[11] - media: UnfurledMediaItem + # TODO: this will be renamed to `media` + image: UnfurledMediaItem description: NotRequired[str] spoiler: NotRequired[bool] @@ -200,8 +204,8 @@ class ContainerComponent(_BaseComponent): components: List[ Union[ ActionRow, - TextDisplayComponent, SectionComponent, + TextDisplayComponent, MediaGalleryComponent, FileComponent, SeparatorComponent, From cf7470132ac08b01e448818c82938dae93657dea Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 25 Jan 2025 16:04:15 +0100 Subject: [PATCH 006/104] fix: thumbnail media field has been renamed now --- disnake/types/components.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/disnake/types/components.py b/disnake/types/components.py index 668c3491aa..6103d6ce95 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -168,8 +168,7 @@ class TextDisplayComponent(_BaseComponent): # note, can't be used at top level, appears to be exclusively for `SectionComponent.accessory`? class ThumbnailComponent(_BaseComponent): type: Literal[11] - # TODO: this will be renamed to `media` - image: UnfurledMediaItem + media: UnfurledMediaItem description: NotRequired[str] spoiler: NotRequired[bool] From bd9662442f4cbe4b6a8b4d017ad4e7001953c293 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Tue, 4 Feb 2025 16:08:49 +0100 Subject: [PATCH 007/104] chore: update comment now that sections allow buttons too --- disnake/types/components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/disnake/types/components.py b/disnake/types/components.py index 6103d6ce95..026300dd69 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -155,8 +155,8 @@ class UnfurledMediaItem(TypedDict): class SectionComponent(_BaseComponent): type: Literal[9] components: List[TextDisplayComponent] - # this currently only supports ThumbnailComponent, others will be added in the future - # (the API seemingly also allows buttons, but they don't render yet) + # this currently only supports ThumbnailComponent and ButtonComponent, + # others will be added in the future accessory: Component From e06c0770d8358e8b613a29989689f31b6edab549 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Thu, 20 Feb 2025 18:46:31 +0100 Subject: [PATCH 008/104] fix: make `SectionComponent.components` typing more permissive --- disnake/types/components.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/disnake/types/components.py b/disnake/types/components.py index 026300dd69..45444034c7 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -152,9 +152,11 @@ class UnfurledMediaItem(TypedDict): # XXX: drop `Component` suffix? `ButtonComponent` also uses it, selects don't. +# TODO: tighten component typings here, plain buttons and such are impossible in `components` or `accessory` class SectionComponent(_BaseComponent): type: Literal[9] - components: List[TextDisplayComponent] + # note: this will currently always be TextDisplayComponent; may or may not be expanded to more types in the future + components: List[Component] # this currently only supports ThumbnailComponent and ButtonComponent, # others will be added in the future accessory: Component From f2f2817e1532cdd7686822f45e6e0bc03038d05b Mon Sep 17 00:00:00 2001 From: shiftinv Date: Thu, 20 Feb 2025 19:38:12 +0100 Subject: [PATCH 009/104] feat: add `SeparatorSpacingSize` enum --- disnake/enums.py | 13 +++++++++++++ disnake/types/components.py | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/disnake/enums.py b/disnake/enums.py index b75c0eb213..49f102908d 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -76,6 +76,7 @@ "PollLayoutType", "VoiceChannelEffectAnimationType", "MessageReferenceType", + "SeparatorSpacingSize", ) @@ -2355,6 +2356,18 @@ class MessageReferenceType(Enum): """Reference used to point to a message at a point in time (forward).""" +class SeparatorSpacingSize(Enum): + """Specifies the size of a :class:`Separator` component. + + .. versionadded:: 2.11 + """ + + small = 1 + """TODO""" + large = 2 + """TODO""" + + T = TypeVar("T") diff --git a/disnake/types/components.py b/disnake/types/components.py index 45444034c7..9c9ea2ab55 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -13,7 +13,7 @@ ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextInputStyle = Literal[1, 2] -SeparatorSpacing = Literal[1, 2] +SeparatorSpacingSize = Literal[1, 2] SelectDefaultValueType = Literal["user", "role", "channel"] @@ -195,7 +195,7 @@ class FileComponent(_BaseComponent): class SeparatorComponent(_BaseComponent): type: Literal[14] divider: NotRequired[bool] - spacing: NotRequired[SeparatorSpacing] + spacing: NotRequired[SeparatorSpacingSize] class ContainerComponent(_BaseComponent): From 93902f57ae1cb7e3802ea8dc292e2d115159cf07 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Thu, 20 Feb 2025 19:40:23 +0100 Subject: [PATCH 010/104] feat(components): implement new component classes roughly --- disnake/components.py | 225 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) diff --git a/disnake/components.py b/disnake/components.py index 8fbc1eaa2a..87283095e3 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -18,11 +18,13 @@ cast, ) +from .colour import Colour from .enums import ( ButtonStyle, ChannelType, ComponentType, SelectDefaultValueType, + SeparatorSpacingSize, TextInputStyle, try_enum, ) @@ -40,12 +42,20 @@ ButtonComponent as ButtonComponentPayload, ChannelSelectMenu as ChannelSelectMenuPayload, Component as ComponentPayload, + ContainerComponent as ContainerComponentPayload, + FileComponent as FileComponentPayload, + MediaGalleryComponent as MediaGalleryComponentPayload, + MediaGalleryItem as MediaGalleryItemPayload, MentionableSelectMenu as MentionableSelectMenuPayload, RoleSelectMenu as RoleSelectMenuPayload, + SectionComponent as SectionComponentPayload, SelectDefaultValue as SelectDefaultValuePayload, SelectOption as SelectOptionPayload, + SeparatorComponent as SeparatorComponentPayload, StringSelectMenu as StringSelectMenuPayload, + TextDisplayComponent as TextDisplayComponentPayload, TextInput as TextInputPayload, + ThumbnailComponent as ThumbnailComponentPayload, UserSelectMenu as UserSelectMenuPayload, ) @@ -63,6 +73,14 @@ "SelectOption", "SelectDefaultValue", "TextInput", + "Section", + "TextDisplay", + "Thumbnail", + "MediaGallery", + "MediaGalleryItem", + "File", + "Separator", + "Container", ) C = TypeVar("C", bound="Component") @@ -83,6 +101,8 @@ ComponentType.channel_select, ] +# TODO: update type aliases for cv2 + MessageComponent = Union["Button", "AnySelectMenu"] ModalComponent: TypeAlias = "TextInput" @@ -783,6 +803,211 @@ def to_dict(self) -> TextInputPayload: return payload +class Section(Component): + """TODO""" + + __slots__: Tuple[str, ...] = ("accessory", "components") + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + # TODO: consider making typehints for `accessory` and `components` more accurate, while keeping runtime behavior generic + def __init__(self, data: SectionComponentPayload) -> None: + self.type: Literal[ComponentType.section] = ComponentType.section + self.accessory: Component = _component_factory(data["accessory"]) + # TODO: reconsider `components` vs `children` + self.components: List[Component] = [ + _component_factory(d) for d in data.get("components", []) + ] + + def to_dict(self) -> SectionComponentPayload: + return { + "type": self.type.value, + "accessory": self.accessory.to_dict(), # type: ignore # FIXME: see comment above + "components": [child.to_dict() for child in self.components], + } + + +class TextDisplay(Component): + """TODO""" + + __slots__: Tuple[str, ...] = ("content",) + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: TextDisplayComponentPayload) -> None: + self.type: Literal[ComponentType.text_display] = ComponentType.text_display + self.content: str = data["content"] + + def to_dict(self) -> TextDisplayComponentPayload: + return { + "type": self.type.value, + "content": self.content, + } + + +class Thumbnail(Component): + """TODO""" + + __slots__: Tuple[str, ...] = ( + "media", + "description", + "spoiler", + ) + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: ThumbnailComponentPayload) -> None: + self.type: Literal[ComponentType.thumbnail] = ComponentType.thumbnail + self.media: Any = data["media"] # TODO: UnfurledMediaItem + self.description: Optional[str] = data.get("description") + self.spoiler: bool = data.get("spoiler", False) + + def to_dict(self) -> ThumbnailComponentPayload: + payload: ThumbnailComponentPayload = { + "type": self.type.value, + "media": self.media, + "spoiler": self.spoiler, + } + + if self.description: + payload["description"] = self.description + + return payload + + +class MediaGallery(Component): + """TODO""" + + __slots__: Tuple[str, ...] = ("items",) + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: MediaGalleryComponentPayload) -> None: + self.type: Literal[ComponentType.media_gallery] = ComponentType.media_gallery + # XXX: `items` vs `children` etc.? + self.items: List[MediaGalleryItem] = [MediaGalleryItem(i) for i in data["items"]] + + def to_dict(self) -> MediaGalleryComponentPayload: + return { + "type": self.type.value, + "items": [i.to_dict() for i in self.items], + } + + +class MediaGalleryItem: + """TODO""" + + __slots__: Tuple[str, ...] = ( + "media", + "description", + "spoiler", + ) + + # XXX: should this be user-instantiable? + def __init__(self, data: MediaGalleryItemPayload) -> None: + self.media: Any = data["media"] # TODO: UnfurledMediaItem + self.description: Optional[str] = data.get("description") + self.spoiler: bool = data.get("spoiler", False) + + def to_dict(self) -> MediaGalleryItemPayload: + payload: MediaGalleryItemPayload = { + "media": self.media, + "spoiler": self.spoiler, + } + + if self.description: + payload["description"] = self.description + + return payload + + def __repr__(self) -> str: + return f"" + + +class File(Component): + """TODO""" + + __slots__: Tuple[str, ...] = ("file", "spoiler") + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: FileComponentPayload) -> None: + self.type: Literal[ComponentType.file] = ComponentType.file + self.file: Any = data["file"] # TODO: UnfurledMediaItem + self.spoiler: bool = data.get("spoiler", False) + + def to_dict(self) -> FileComponentPayload: + return { + "type": self.type.value, + "file": self.file, + "spoiler": self.spoiler, + } + + +class Separator(Component): + """TODO""" + + __slots__: Tuple[str, ...] = ("divider", "spacing") + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: SeparatorComponentPayload) -> None: + self.type: Literal[ComponentType.separator] = ComponentType.separator + self.divider: bool = data.get("divider", True) + self.spacing: SeparatorSpacingSize = try_enum(SeparatorSpacingSize, data.get("spacing", 1)) + + def to_dict(self) -> SeparatorComponentPayload: + return { + "type": self.type.value, + "divider": self.divider, + "spacing": self.spacing.value, + } + + +class Container(Component): + """TODO""" + + __slots__: Tuple[str, ...] = ( + "_accent_colour", + "spoiler", + "components", + ) + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: ContainerComponentPayload) -> None: + self.type: Literal[ComponentType.container] = ComponentType.container + self._accent_colour: Optional[int] = data.get("accent_color") + self.spoiler: bool = data.get("spoiler", False) + # TODO: once again, reconsider `components` vs `children` + # TODO: stricter types + self.components: List[Component] = [ + _component_factory(d) for d in data.get("components", []) + ] + + def to_dict(self) -> ContainerComponentPayload: + payload: ContainerComponentPayload = { + "type": self.type.value, + "spoiler": self.spoiler, + "components": [child.to_dict() for child in self.components], # type: ignore # FIXME: see Section component + } + + if self._accent_colour is not None: + payload["accent_color"] = self._accent_colour + + return payload + + @property + def accent_colour(self) -> Optional[Colour]: + """TODO""" + return Colour(self._accent_colour) if self._accent_colour is not None else None + + @property + def accent_color(self) -> Optional[Colour]: + """TODO""" + return self.accent_colour + + def _component_factory(data: ComponentPayload, *, type: Type[C] = Component) -> C: # NOTE: due to speed, this method does not use the ComponentType enum # as this runs every single time a component is received from the api From 7765c462d9449d7e5d784326510c3721e1246c71 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Thu, 20 Feb 2025 19:45:03 +0100 Subject: [PATCH 011/104] fix(components): rename `components.File` -> `components.FileComponent` for now to avoid shadowing --- disnake/components.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 87283095e3..b26ff06988 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -78,7 +78,7 @@ "Thumbnail", "MediaGallery", "MediaGalleryItem", - "File", + "FileComponent", "Separator", "Container", ) @@ -924,7 +924,8 @@ def __repr__(self) -> str: return f"" -class File(Component): +# TODO: temporary name to avoid shadowing `disnake.file.File` +class FileComponent(Component): """TODO""" __slots__: Tuple[str, ...] = ("file", "spoiler") From 02223777045360ce30589f7a57d161ae286ab9fd Mon Sep 17 00:00:00 2001 From: shiftinv Date: Thu, 20 Feb 2025 19:46:06 +0100 Subject: [PATCH 012/104] feat: add new components to factory function --- disnake/components.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/disnake/components.py b/disnake/components.py index b26ff06988..c77e5e08ef 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -120,6 +120,9 @@ class Component: - subtypes of :class:`BaseSelectMenu` (:class:`ChannelSelectMenu`, :class:`MentionableSelectMenu`, :class:`RoleSelectMenu`, :class:`StringSelectMenu`, :class:`UserSelectMenu`) - :class:`TextInput` + .. + TODO: add cv2 components to list + This class is abstract and cannot be instantiated. .. versionadded:: 2.0 @@ -1009,6 +1012,7 @@ def accent_color(self) -> Optional[Colour]: return self.accent_colour +# TODO: this should use a static mapping def _component_factory(data: ComponentPayload, *, type: Type[C] = Component) -> C: # NOTE: due to speed, this method does not use the ComponentType enum # as this runs every single time a component is received from the api @@ -1030,6 +1034,20 @@ def _component_factory(data: ComponentPayload, *, type: Type[C] = Component) -> return MentionableSelectMenu(data) # type: ignore elif component_type == 8: return ChannelSelectMenu(data) # type: ignore + elif component_type == 9: + return Section(data) # type: ignore + elif component_type == 10: + return TextDisplay(data) # type: ignore + elif component_type == 11: + return Thumbnail(data) # type: ignore + elif component_type == 12: + return MediaGallery(data) # type: ignore + elif component_type == 13: + return FileComponent(data) # type: ignore + elif component_type == 14: + return Separator(data) # type: ignore + elif component_type == 17: + return Container(data) # type: ignore else: assert_never(component_type) as_enum = try_enum(ComponentType, component_type) From 9bb4013da36ff0c53aae38bd6ecd8c1abfe275e9 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Thu, 20 Feb 2025 19:55:40 +0100 Subject: [PATCH 013/104] fix: improve `Container` repr --- disnake/components.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/disnake/components.py b/disnake/components.py index c77e5e08ef..bdbc295a84 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -977,7 +977,11 @@ class Container(Component): "components", ) - __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + __repr_info__: ClassVar[Tuple[str, ...]] = tuple( + # no comment. it is what it is. + ("accent_colour" if s == "_accent_colour" else s) + for s in __slots__ + ) def __init__(self, data: ContainerComponentPayload) -> None: self.type: Literal[ComponentType.container] = ComponentType.container From 928033ce3114970975a3b232cec2fab450204227 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 21 Feb 2025 13:31:37 +0100 Subject: [PATCH 014/104] refactor: rename existing action row component type aliases --- disnake/components.py | 17 +++++++++-------- disnake/interactions/message.py | 4 ++-- disnake/message.py | 10 +++++----- disnake/types/components.py | 12 +++++------- disnake/ui/action_row.py | 4 ++-- disnake/ui/item.py | 8 ++++---- disnake/ui/view.py | 15 +++++++++------ 7 files changed, 36 insertions(+), 34 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index bdbc295a84..bb93fcfb48 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -83,8 +83,6 @@ "Container", ) -C = TypeVar("C", bound="Component") - AnySelectMenu = Union[ "StringSelectMenu", "UserSelectMenu", @@ -103,11 +101,11 @@ # TODO: update type aliases for cv2 -MessageComponent = Union["Button", "AnySelectMenu"] -ModalComponent: TypeAlias = "TextInput" +ActionRowMessageComponent = Union["Button", "AnySelectMenu"] +ActionRowModalComponent: TypeAlias = "TextInput" -NestedComponent = Union[MessageComponent, ModalComponent] -ComponentT = TypeVar("ComponentT", bound=NestedComponent) +ActionRowChildComponent = Union[ActionRowMessageComponent, ActionRowModalComponent] +ActionRowChildComponentT = TypeVar("ActionRowChildComponentT", bound=ActionRowChildComponent) class Component: @@ -158,7 +156,7 @@ def to_dict(self) -> Dict[str, Any]: raise NotImplementedError -class ActionRow(Component, Generic[ComponentT]): +class ActionRow(Component, Generic[ActionRowChildComponentT]): """Represents an action row. This is a component that holds up to 5 children components in a row. @@ -180,7 +178,7 @@ class ActionRow(Component, Generic[ComponentT]): def __init__(self, data: ActionRowPayload) -> None: 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 + self.children: List[ActionRowChildComponentT] = children # type: ignore def to_dict(self) -> ActionRowPayload: return { @@ -1016,6 +1014,9 @@ def accent_color(self) -> Optional[Colour]: return self.accent_colour +C = TypeVar("C", bound="Component") + + # TODO: this should use a static mapping def _component_factory(data: ComponentPayload, *, type: Type[C] = Component) -> C: # NOTE: due to speed, this method does not use the ComponentType enum diff --git a/disnake/interactions/message.py b/disnake/interactions/message.py index 5205bbfe56..32fcc47d21 100644 --- a/disnake/interactions/message.py +++ b/disnake/interactions/message.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union -from ..components import MessageComponent +from ..components import ActionRowMessageComponent from ..enums import ComponentType, try_enum from ..message import Message from ..utils import cached_slot_property @@ -166,7 +166,7 @@ def resolved_values( return values @cached_slot_property("_cs_component") - def component(self) -> MessageComponent: + def component(self) -> ActionRowMessageComponent: """Union[:class:`Button`, :class:`BaseSelectMenu`]: The component the user interacted with""" for action_row in self.message.components: for component in action_row.children: diff --git a/disnake/message.py b/disnake/message.py index 9dcbdfe16b..971fcfe012 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -24,7 +24,7 @@ from . import utils from .channel import PartialMessageable -from .components import ActionRow, MessageComponent, _component_factory +from .components import ActionRow, ActionRowMessageComponent, _component_factory from .embeds import Embed from .emoji import Emoji from .enums import ( @@ -1185,8 +1185,8 @@ def __init__( self.stickers: List[StickerItem] = [ StickerItem(data=d, state=state) for d in data.get("sticker_items", []) ] - self.components: List[ActionRow[MessageComponent]] = [ - _component_factory(d, type=ActionRow[MessageComponent]) + self.components: List[ActionRow[ActionRowMessageComponent]] = [ + _component_factory(d, type=ActionRow[ActionRowMessageComponent]) for d in data.get("components", []) ] @@ -1435,7 +1435,7 @@ def _handle_mention_roles(self, role_mentions: List[int]) -> None: def _handle_components(self, components: List[ComponentPayload]) -> None: self.components = [ - _component_factory(d, type=ActionRow[MessageComponent]) for d in components + _component_factory(d, type=ActionRow[ActionRowMessageComponent]) for d in components ] def _rebind_cached_references(self, new_guild: Guild, new_channel: GuildMessageable) -> None: @@ -2930,7 +2930,7 @@ def __init__( StickerItem(data=d, state=state) for d in data.get("sticker_items", []) ] self.components = [ - _component_factory(d, type=ActionRow[MessageComponent]) + _component_factory(d, type=ActionRow[ActionRowMessageComponent]) for d in data.get("components", []) ] self.guild_id = guild_id diff --git a/disnake/types/components.py b/disnake/types/components.py index 9c9ea2ab55..fc3651f4d9 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -37,7 +37,7 @@ class _BaseComponent(TypedDict): # type: ComponentType # FIXME: current version of pyright complains about overriding types, latest might be fine - # TODO: always present in responses? + # TODO: always present in responses id: NotRequired[int] # NOTE: not implemented (yet?) @@ -151,14 +151,12 @@ class UnfurledMediaItem(TypedDict): url: str -# XXX: drop `Component` suffix? `ButtonComponent` also uses it, selects don't. -# TODO: tighten component typings here, plain buttons and such are impossible in `components` or `accessory` +# TODO: tighten component typings here, plain buttons and such are impossible in `components` class SectionComponent(_BaseComponent): type: Literal[9] - # note: this will currently always be TextDisplayComponent; may or may not be expanded to more types in the future + # note: currently always TextDisplayComponent, but don't hardcode assumptions components: List[Component] - # this currently only supports ThumbnailComponent and ButtonComponent, - # others will be added in the future + # note: currently only supports ThumbnailComponent and ButtonComponent, same as above accessory: Component @@ -167,7 +165,7 @@ class TextDisplayComponent(_BaseComponent): content: str -# note, can't be used at top level, appears to be exclusively for `SectionComponent.accessory`? +# note, can't be used at top level, this is exclusively for use in `SectionComponent.accessory` class ThumbnailComponent(_BaseComponent): type: Literal[11] media: UnfurledMediaItem diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index ac2fa16eca..399d8f1301 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -21,10 +21,10 @@ from ..components import ( ActionRow as ActionRowComponent, + ActionRowChildComponent, Button as ButtonComponent, ChannelSelectMenu as ChannelSelectComponent, MentionableSelectMenu as MentionableSelectComponent, - NestedComponent, RoleSelectMenu as RoleSelectComponent, StringSelectMenu as StringSelectComponent, UserSelectMenu as UserSelectComponent, @@ -715,7 +715,7 @@ def pop(self, index: int) -> UIComponentT: return component @property - def _underlying(self) -> ActionRowComponent[NestedComponent]: + def _underlying(self) -> ActionRowComponent[ActionRowChildComponent]: return ActionRowComponent._raw_construct( type=self.type, children=[comp._underlying for comp in self._children], diff --git a/disnake/ui/item.py b/disnake/ui/item.py index 927b1e0d98..9aea20d7d1 100644 --- a/disnake/ui/item.py +++ b/disnake/ui/item.py @@ -26,7 +26,7 @@ from typing_extensions import Self from ..client import Client - from ..components import NestedComponent + from ..components import ActionRowChildComponent from ..enums import ComponentType from ..interactions import MessageInteraction from ..types.components import Component as ComponentPayload @@ -56,7 +56,7 @@ class WrappedComponent(ABC): @property @abstractmethod - def _underlying(self) -> NestedComponent: ... + def _underlying(self) -> ActionRowChildComponent: ... @property @abstractmethod @@ -108,14 +108,14 @@ def __init__(self) -> None: # only called upon edit and we're mainly interested during initial creation time. self._provided_custom_id: bool = False - def refresh_component(self, component: NestedComponent) -> None: + def refresh_component(self, component: ActionRowChildComponent) -> None: return None def refresh_state(self, interaction: MessageInteraction) -> None: return None @classmethod - def from_component(cls, component: NestedComponent) -> Self: + def from_component(cls, component: ActionRowChildComponent) -> Self: return cls() def is_dispatchable(self) -> bool: diff --git a/disnake/ui/view.py b/disnake/ui/view.py index 09958066c7..8aa34b13dd 100644 --- a/disnake/ui/view.py +++ b/disnake/ui/view.py @@ -23,10 +23,10 @@ from ..components import ( ActionRow as ActionRowComponent, + ActionRowMessageComponent, Button as ButtonComponent, ChannelSelectMenu as ChannelSelectComponent, MentionableSelectMenu as MentionableSelectComponent, - MessageComponent, RoleSelectMenu as RoleSelectComponent, StringSelectMenu as StringSelectComponent, UserSelectMenu as UserSelectComponent, @@ -51,13 +51,13 @@ def _walk_all_components( - components: List[ActionRowComponent[MessageComponent]], -) -> Iterator[MessageComponent]: + components: List[ActionRowComponent[ActionRowMessageComponent]], +) -> Iterator[ActionRowMessageComponent]: for item in components: yield from item.children -def _component_to_item(component: MessageComponent) -> Item: +def _component_to_item(component: ActionRowMessageComponent) -> Item: if isinstance(component, ButtonComponent): from .button import Button @@ -412,7 +412,7 @@ def _dispatch_item(self, item: Item, interaction: MessageInteraction) -> None: self._scheduled_task(item, interaction), name=f"disnake-ui-view-dispatch-{self.id}" ) - def refresh(self, components: List[ActionRowComponent[MessageComponent]]) -> None: + def refresh(self, components: List[ActionRowComponent[ActionRowMessageComponent]]) -> None: # TODO: this is pretty hacky at the moment, see https://github.com/DisnakeDev/disnake/commit/9384a72acb8c515b13a600592121357e165368da old_state: Dict[Tuple[int, str], Item] = { (item.type.value, item.custom_id): item # type: ignore @@ -573,5 +573,8 @@ def update_from_message(self, message_id: int, components: List[ComponentPayload # pre-req: is_message_tracked == true view = self._synced_message_views[message_id] view.refresh( - [_component_factory(d, type=ActionRowComponent[MessageComponent]) for d in components] + [ + _component_factory(d, type=ActionRowComponent[ActionRowMessageComponent]) + for d in components + ] ) From b37c87c3512f1c2f8c5bfbc79f70bacba3cca5a4 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 21 Feb 2025 14:05:19 +0100 Subject: [PATCH 015/104] perf: use mapping for `_component_factory` --- disnake/components.py | 62 ++++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index bb93fcfb48..fd67fbb945 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -10,6 +10,7 @@ Generic, List, Literal, + Mapping, Optional, Tuple, Type, @@ -29,7 +30,7 @@ try_enum, ) from .partial_emoji import PartialEmoji, _EmojiTag -from .utils import MISSING, _get_as_snowflake, assert_never, get_slots +from .utils import MISSING, _get_as_snowflake, get_slots if TYPE_CHECKING: from typing_extensions import Self, TypeAlias @@ -42,6 +43,7 @@ ButtonComponent as ButtonComponentPayload, ChannelSelectMenu as ChannelSelectMenuPayload, Component as ComponentPayload, + ComponentType as ComponentTypeLiteral, ContainerComponent as ContainerComponentPayload, FileComponent as FileComponentPayload, MediaGalleryComponent as MediaGalleryComponentPayload, @@ -1017,43 +1019,31 @@ def accent_color(self) -> Optional[Colour]: C = TypeVar("C", bound="Component") -# TODO: this should use a static mapping +COMPONENT_LOOKUP: Mapping[ComponentTypeLiteral, Type[Component]] = { + ComponentType.action_row.value: ActionRow, + ComponentType.button.value: Button, + ComponentType.string_select.value: StringSelectMenu, + ComponentType.text_input.value: TextInput, + ComponentType.user_select.value: UserSelectMenu, + ComponentType.role_select.value: RoleSelectMenu, + ComponentType.mentionable_select.value: MentionableSelectMenu, + ComponentType.channel_select.value: ChannelSelectMenu, + ComponentType.section.value: Section, + ComponentType.text_display.value: TextDisplay, + ComponentType.thumbnail.value: Thumbnail, + ComponentType.media_gallery.value: MediaGallery, + ComponentType.file.value: FileComponent, + ComponentType.separator.value: Separator, + ComponentType.container.value: Container, +} + + +# NOTE: The type param is purely for type-checking, it has no implications on runtime behavior. def _component_factory(data: ComponentPayload, *, type: Type[C] = Component) -> C: - # NOTE: due to speed, this method does not use the ComponentType enum - # as this runs every single time a component is received from the api - # NOTE: The type param is purely for type-checking, it has no implications on runtime behavior. component_type = data["type"] - if component_type == 1: - return ActionRow(data) # type: ignore - elif component_type == 2: - return Button(data) # type: ignore - elif component_type == 3: - return StringSelectMenu(data) # type: ignore - elif component_type == 4: - return TextInput(data) # type: ignore - elif component_type == 5: - return UserSelectMenu(data) # type: ignore - elif component_type == 6: - return RoleSelectMenu(data) # type: ignore - elif component_type == 7: - return MentionableSelectMenu(data) # type: ignore - elif component_type == 8: - return ChannelSelectMenu(data) # type: ignore - elif component_type == 9: - return Section(data) # type: ignore - elif component_type == 10: - return TextDisplay(data) # type: ignore - elif component_type == 11: - return Thumbnail(data) # type: ignore - elif component_type == 12: - return MediaGallery(data) # type: ignore - elif component_type == 13: - return FileComponent(data) # type: ignore - elif component_type == 14: - return Separator(data) # type: ignore - elif component_type == 17: - return Container(data) # type: ignore + + if component_cls := COMPONENT_LOOKUP.get(component_type): + return component_cls(data) # type: ignore else: - assert_never(component_type) as_enum = try_enum(ComponentType, component_type) return Component._raw_construct(type=as_enum) # type: ignore From ff6c20d14f06c82e59a2f80ba39096a5e6bf9a18 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 21 Feb 2025 15:24:23 +0100 Subject: [PATCH 016/104] refactor: make child component typehints more specific --- disnake/components.py | 48 ++++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index fd67fbb945..8b4512850c 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -101,14 +101,29 @@ ComponentType.channel_select, ] -# TODO: update type aliases for cv2 - +# valid `ActionRow.components` item types in a message/modal ActionRowMessageComponent = Union["Button", "AnySelectMenu"] ActionRowModalComponent: TypeAlias = "TextInput" +# any child component type of action rows ActionRowChildComponent = Union[ActionRowMessageComponent, ActionRowModalComponent] ActionRowChildComponentT = TypeVar("ActionRowChildComponentT", bound=ActionRowChildComponent) +# valid `Section.accessory` types +SectionAccessoryComponent = Union["Thumbnail", "Button"] +# valid `Section.components` item types +SectionChildComponent: TypeAlias = "TextDisplay" + +# valid `Container.components` item types +ContainerChildComponent = Union[ + "ActionRow[ActionRowMessageComponent]", + "Section", + "TextDisplay", + "MediaGallery", + "FileComponent", + "Separator", +] + class Component: """Represents a Discord Bot UI Kit Component. @@ -813,19 +828,21 @@ class Section(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - # TODO: consider making typehints for `accessory` and `components` more accurate, while keeping runtime behavior generic def __init__(self, data: SectionComponentPayload) -> None: self.type: Literal[ComponentType.section] = ComponentType.section - self.accessory: Component = _component_factory(data["accessory"]) + + accessory = _component_factory(data["accessory"]) + self.accessory: SectionAccessoryComponent = accessory # type: ignore + # TODO: reconsider `components` vs `children` - self.components: List[Component] = [ - _component_factory(d) for d in data.get("components", []) + self.components: List[SectionChildComponent] = [ + _component_factory(d, type=SectionChildComponent) for d in data.get("components", []) ] def to_dict(self) -> SectionComponentPayload: return { "type": self.type.value, - "accessory": self.accessory.to_dict(), # type: ignore # FIXME: see comment above + "accessory": self.accessory.to_dict(), "components": [child.to_dict() for child in self.components], } @@ -977,27 +994,26 @@ class Container(Component): "components", ) - __repr_info__: ClassVar[Tuple[str, ...]] = tuple( - # no comment. it is what it is. - ("accent_colour" if s == "_accent_colour" else s) - for s in __slots__ + __repr_info__: ClassVar[Tuple[str, ...]] = ( + "accent_colour", + "spoiler", + "components", ) def __init__(self, data: ContainerComponentPayload) -> None: self.type: Literal[ComponentType.container] = ComponentType.container self._accent_colour: Optional[int] = data.get("accent_color") self.spoiler: bool = data.get("spoiler", False) + # TODO: once again, reconsider `components` vs `children` - # TODO: stricter types - self.components: List[Component] = [ - _component_factory(d) for d in data.get("components", []) - ] + components = [_component_factory(d) for d in data.get("components", [])] + self.components: List[ContainerChildComponent] = components # type: ignore def to_dict(self) -> ContainerComponentPayload: payload: ContainerComponentPayload = { "type": self.type.value, "spoiler": self.spoiler, - "components": [child.to_dict() for child in self.components], # type: ignore # FIXME: see Section component + "components": [child.to_dict() for child in self.components], } if self._accent_colour is not None: From 2522af4c224c0c3dab83c0e04ce08498ce73f6c1 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 21 Feb 2025 15:29:11 +0100 Subject: [PATCH 017/104] chore: move some TODOs --- disnake/components.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 8b4512850c..1e3a71c2f7 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -821,6 +821,12 @@ def to_dict(self) -> TextInputPayload: return payload +# list of more general TODOs: +# TODO: reconsider `components` vs `items` vs `children` +# TODO: deserialize UnfurledMediaItem (?) +# TODO: docstrings + + class Section(Component): """TODO""" @@ -834,7 +840,6 @@ def __init__(self, data: SectionComponentPayload) -> None: accessory = _component_factory(data["accessory"]) self.accessory: SectionAccessoryComponent = accessory # type: ignore - # TODO: reconsider `components` vs `children` self.components: List[SectionChildComponent] = [ _component_factory(d, type=SectionChildComponent) for d in data.get("components", []) ] @@ -904,7 +909,6 @@ class MediaGallery(Component): def __init__(self, data: MediaGalleryComponentPayload) -> None: self.type: Literal[ComponentType.media_gallery] = ComponentType.media_gallery - # XXX: `items` vs `children` etc.? self.items: List[MediaGalleryItem] = [MediaGalleryItem(i) for i in data["items"]] def to_dict(self) -> MediaGalleryComponentPayload: @@ -1005,7 +1009,6 @@ def __init__(self, data: ContainerComponentPayload) -> None: self._accent_colour: Optional[int] = data.get("accent_color") self.spoiler: bool = data.get("spoiler", False) - # TODO: once again, reconsider `components` vs `children` components = [_component_factory(d) for d in data.get("components", [])] self.components: List[ContainerChildComponent] = components # type: ignore From 103c1c23db816012ca9f20ce17a93986b61b6c70 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 21 Feb 2025 16:09:57 +0100 Subject: [PATCH 018/104] docs: add docstrings for new components --- disnake/components.py | 135 +++++++++++++++++++++++++++++++++++++--- docs/api/components.rst | 77 +++++++++++++++++++++++ 2 files changed, 202 insertions(+), 10 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 1e3a71c2f7..9f9438d22d 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -824,11 +824,28 @@ def to_dict(self) -> TextInputPayload: # list of more general TODOs: # TODO: reconsider `components` vs `items` vs `children` # TODO: deserialize UnfurledMediaItem (?) -# TODO: docstrings +# TODO: adopt descriptions from API docs once published +# TODO: document limits class Section(Component): - """TODO""" + """Represents a section from the Discord Bot UI Kit (v2). + + This allows displaying an accessory (thumbnail or button) next to a block of text. + + .. note:: + The user constructible and usable type to create a + section is :class:`disnake.ui.Section`. + + .. versionadded:: 2.11 + + Attributes + ---------- + accessory: Union[:class:`Thumbnail`, :class:`Button`] + The accessory component displayed next to the section text. + components: List[:class:`TextDisplay`] + The text items in this section. + """ __slots__: Tuple[str, ...] = ("accessory", "components") @@ -853,7 +870,19 @@ def to_dict(self) -> SectionComponentPayload: class TextDisplay(Component): - """TODO""" + """Represents a text display from the Discord Bot UI Kit (v2). + + .. note:: + The user constructible and usable type to create a + text display is :class:`disnake.ui.TextDisplay`. + + .. versionadded:: 2.11 + + Attributes + ---------- + content: :class:`str` + The text displayed by this component. + """ __slots__: Tuple[str, ...] = ("content",) @@ -871,7 +900,25 @@ def to_dict(self) -> TextDisplayComponentPayload: class Thumbnail(Component): - """TODO""" + """Represents a thumbnail from the Discord Bot UI Kit (v2). + + This is only supported as the :attr:`~Section.accessory` of a section component. + + .. note:: + The user constructible and usable type to create a + thumbnail is :class:`disnake.ui.Thumbnail`. + + .. versionadded:: 2.11 + + Attributes + ---------- + media: Any + n/a + description: Optional[:class:`str`] + The thumbnail's description ("alt text"), if any. + spoiler: :class:`bool` + Whether the thumbnail is marked as a spoiler. Defaults to ``False``. + """ __slots__: Tuple[str, ...] = ( "media", @@ -901,7 +948,21 @@ def to_dict(self) -> ThumbnailComponentPayload: class MediaGallery(Component): - """TODO""" + """Represents a media gallery from the Discord Bot UI Kit (v2). + + This allows displaying up to 10 images in a gallery. + + .. note:: + The user constructible and usable type to create a + media gallery is :class:`disnake.ui.MediaGallery`. + + .. versionadded:: 2.11 + + Attributes + ---------- + items: List[:class:`MediaGalleryItem`] + The images in this gallery. + """ __slots__: Tuple[str, ...] = ("items",) @@ -950,7 +1011,23 @@ def __repr__(self) -> str: # TODO: temporary name to avoid shadowing `disnake.file.File` class FileComponent(Component): - """TODO""" + """Represents a file component from the Discord Bot UI Kit (v2). + + This allows displaying attached files. + + .. note:: + The user constructible and usable type to create a + file component is :class:`disnake.ui.File`. + + .. versionadded:: 2.11 + + Attributes + ---------- + file: Any + n/a + spoiler: :class:`bool` + Whether the file is marked as a spoiler. Defaults to ``False``. + """ __slots__: Tuple[str, ...] = ("file", "spoiler") @@ -970,7 +1047,24 @@ def to_dict(self) -> FileComponentPayload: class Separator(Component): - """TODO""" + """Represents a separator from the Discord Bot UI Kit (v2). + + This allows vertically separating components. + + .. note:: + The user constructible and usable type to create a + separator is :class:`disnake.ui.Separator`. + + .. versionadded:: 2.11 + + Attributes + ---------- + divider: :class:`bool` + Whether the separator should be visible, instead of just being vertical padding/spacing. + Defaults to ``True``. + spacing: :class:`SeparatorSpacingSize` + The size of the separator. + """ __slots__: Tuple[str, ...] = ("divider", "spacing") @@ -979,6 +1073,7 @@ class Separator(Component): def __init__(self, data: SeparatorComponentPayload) -> None: self.type: Literal[ComponentType.separator] = ComponentType.separator self.divider: bool = data.get("divider", True) + # TODO: `size` instead of `spacing`? self.spacing: SeparatorSpacingSize = try_enum(SeparatorSpacingSize, data.get("spacing", 1)) def to_dict(self) -> SeparatorComponentPayload: @@ -990,7 +1085,23 @@ def to_dict(self) -> SeparatorComponentPayload: class Container(Component): - """TODO""" + """Represents a container from the Discord Bot UI Kit (v2). + + This is visually similar to :class:`Embed`\\s, and contains other components. + + .. note:: + The user constructible and usable type to create a + container is :class:`disnake.ui.Container`. + + .. versionadded:: 2.11 + + Attributes + ---------- + spoiler: :class:`bool` + Whether the container is marked as a spoiler. Defaults to ``False``. + components: List[Union[:class:`ActionRow`, :class:`Section`, :class:`TextDisplay`, :class:`MediaGallery`, :class:`FileComponent`, :class:`Separator`]] + The components in this container. + """ __slots__: Tuple[str, ...] = ( "_accent_colour", @@ -1026,12 +1137,16 @@ def to_dict(self) -> ContainerComponentPayload: @property def accent_colour(self) -> Optional[Colour]: - """TODO""" + """Optional[:class:`Colour`]: Returns the accent colour of the container. + An alias exists under ``accent_color``. + """ return Colour(self._accent_colour) if self._accent_colour is not None else None @property def accent_color(self) -> Optional[Colour]: - """TODO""" + """Optional[:class:`Colour`]: Returns the accent color of the container. + An alias exists under ``accent_colour``. + """ return self.accent_colour diff --git a/docs/api/components.rst b/docs/api/components.rst index 14f1153679..f680adffd3 100644 --- a/docs/api/components.rst +++ b/docs/api/components.rst @@ -123,6 +123,77 @@ TextInput :members: :inherited-members: +Section +~~~~~~~ + +.. attributetable:: Section + +.. autoclass:: Section() + :members: + :inherited-members: + +TextDisplay +~~~~~~~~~~~ + +.. attributetable:: TextDisplay + +.. autoclass:: TextDisplay() + :members: + :inherited-members: + +Thumbnail +~~~~~~~~~ + +.. attributetable:: Thumbnail + +.. autoclass:: Thumbnail() + :members: + :inherited-members: + +MediaGallery +~~~~~~~~~~~~ + +.. attributetable:: MediaGallery + +.. autoclass:: MediaGallery() + :members: + :inherited-members: + +MediaGalleryItem +~~~~~~~~~~~~~~~~ + +.. attributetable:: MediaGalleryItem + +.. autoclass:: MediaGalleryItem + :members: + +FileComponent +~~~~~~~~~~~~~ + +.. attributetable:: FileComponent + +.. autoclass:: FileComponent() + :members: + :inherited-members: + +Separator +~~~~~~~~~ + +.. attributetable:: Separator + +.. autoclass:: Separator() + :members: + :inherited-members: + +Container +~~~~~~~~~ + +.. attributetable:: Container + +.. autoclass:: Container() + :members: + :inherited-members: + Enumerations ------------ @@ -149,3 +220,9 @@ SelectDefaultValueType .. autoclass:: SelectDefaultValueType() :members: + +SeparatorSpacingSize +~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: SeparatorSpacingSize() + :members: From a36345114107a036af9386b576da2040f2093718 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 21 Feb 2025 18:07:39 +0100 Subject: [PATCH 019/104] feat(types): make child component types for `ActionRow` and `SectionComponent` more specific --- disnake/components.py | 13 +++++++++++++ disnake/types/components.py | 34 ++++++++++++++++++++++++++-------- disnake/types/interactions.py | 4 ++-- disnake/types/message.py | 6 +++--- 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 9f9438d22d..613cecd9b2 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -124,6 +124,19 @@ "Separator", ] +# valid `Message.components` item types (v1/v2) +MessageTopLevelComponentV1: TypeAlias = "ActionRow[ActionRowMessageComponent]" +MessageTopLevelComponentV2 = Union[ + MessageTopLevelComponentV1, + "Section", + "TextDisplay", + "MediaGallery", + "FileComponent", + "Separator", + "Container", +] +MessageTopLevelComponent = Union[MessageTopLevelComponentV1, MessageTopLevelComponentV2] + class Component: """Represents a Discord Bot UI Kit Component. diff --git a/disnake/types/components.py b/disnake/types/components.py index fc3651f4d9..3b4820fb23 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import List, Literal, TypedDict, Union +from typing import List, Literal, TypeAlias, TypedDict, Union from typing_extensions import NotRequired @@ -17,6 +17,10 @@ SelectDefaultValueType = Literal["user", "role", "channel"] + +# component type aliases/groupings + +# all implemented components Component = Union[ "ActionRow", "ButtonComponent", @@ -24,12 +28,27 @@ "TextInput", "SectionComponent", "TextDisplayComponent", - "ThumbnailComponent", # TODO: reconsider the semantics of this `Component` union, not all of these types can appear in all places + "ThumbnailComponent", "MediaGalleryComponent", + "FileComponent", "SeparatorComponent", + "ContainerComponent", +] + +ActionRowChildComponent = Union["ButtonComponent", "AnySelectMenu", "TextInput"] + +MessageTopLevelComponentV1: TypeAlias = "ActionRow" +# currently, all v2 components except Thumbnail +MessageTopLevelComponentV2 = Union[ + MessageTopLevelComponentV1, + "SectionComponent", + "TextDisplayComponent", + "MediaGalleryComponent", "FileComponent", + "SeparatorComponent", "ContainerComponent", ] +MessageTopLevelComponent = Union[MessageTopLevelComponentV1, MessageTopLevelComponentV2] # base types @@ -43,7 +62,7 @@ class _BaseComponent(TypedDict): class ActionRow(_BaseComponent): type: Literal[1] - components: List[Component] + components: List[ActionRowChildComponent] # button @@ -151,13 +170,12 @@ class UnfurledMediaItem(TypedDict): url: str -# TODO: tighten component typings here, plain buttons and such are impossible in `components` class SectionComponent(_BaseComponent): type: Literal[9] - # note: currently always TextDisplayComponent, but don't hardcode assumptions - components: List[Component] - # note: currently only supports ThumbnailComponent and ButtonComponent, same as above - accessory: Component + # note: this may be expanded to more component types in the future + components: List[TextDisplayComponent] + # note: same as above + accessory: Union[ThumbnailComponent, ButtonComponent] class TextDisplayComponent(_BaseComponent): diff --git a/disnake/types/interactions.py b/disnake/types/interactions.py index 12833beffd..63b934ffd8 100644 --- a/disnake/types/interactions.py +++ b/disnake/types/interactions.py @@ -8,7 +8,7 @@ from .appinfo import ApplicationIntegrationType from .channel import ChannelType -from .components import Component, Modal +from .components import MessageTopLevelComponent, Modal from .embed import Embed from .entitlement import Entitlement from .i18n import LocalizationDict @@ -321,7 +321,7 @@ class InteractionApplicationCommandCallbackData(TypedDict, total=False): embeds: List[Embed] allowed_mentions: AllowedMentions flags: int - components: List[Component] + components: List[MessageTopLevelComponent] attachments: List[Attachment] diff --git a/disnake/types/message.py b/disnake/types/message.py index c3f8e4d1e9..8254b36a24 100644 --- a/disnake/types/message.py +++ b/disnake/types/message.py @@ -7,7 +7,7 @@ from typing_extensions import NotRequired from .channel import ChannelType -from .components import Component +from .components import MessageTopLevelComponent from .embed import Embed from .emoji import PartialEmoji from .interactions import InteractionDataResolved, InteractionMessageReference, InteractionMetadata @@ -89,7 +89,7 @@ class ForwardedMessage(TypedDict): # is not forwarded in the same guild mention_roles: NotRequired[SnowflakeList] sticker_items: NotRequired[List[StickerItem]] - components: NotRequired[List[Component]] + components: NotRequired[List[MessageTopLevelComponent]] class MessageSnapshot(TypedDict): @@ -138,7 +138,7 @@ class Message(TypedDict): interaction: NotRequired[InteractionMessageReference] # deprecated interaction_metadata: NotRequired[InteractionMetadata] thread: NotRequired[Thread] - components: NotRequired[List[Component]] + components: NotRequired[List[MessageTopLevelComponent]] sticker_items: NotRequired[List[StickerItem]] position: NotRequired[int] role_subscription_data: NotRequired[RoleSubscriptionData] From 3cfcdd0ab9c3ff1d007c38ffc1174f5e5dca8951 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 21 Feb 2025 18:14:31 +0100 Subject: [PATCH 020/104] fix(typing): resolve pyright complaints related to `Message.components` --- disnake/ui/view.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/disnake/ui/view.py b/disnake/ui/view.py index 8aa34b13dd..d57df8d1f1 100644 --- a/disnake/ui/view.py +++ b/disnake/ui/view.py @@ -19,6 +19,7 @@ Optional, Sequence, Tuple, + cast, ) from ..components import ( @@ -46,7 +47,11 @@ from ..interactions import MessageInteraction from ..message import Message from ..state import ConnectionState - from ..types.components import ActionRow as ActionRowPayload, Component as ComponentPayload + from ..types.components import ( + ActionRow as ActionRowPayload, + ActionRowChildComponent as ActionRowChildComponentPayload, + Component as ComponentPayload, + ) from .item import ItemCallbackType @@ -213,7 +218,11 @@ def key(item: Item) -> int: children = sorted(self.children, key=key) components: List[ActionRowPayload] = [] for _, group in groupby(children, key=key): - children = [item.to_component_dict() for item in group] + # these will always be valid actionrow child components + children = cast( + "List[ActionRowChildComponentPayload]", + [item.to_component_dict() for item in group], + ) if not children: continue @@ -569,7 +578,7 @@ def is_message_tracked(self, message_id: int) -> bool: def remove_message_tracking(self, message_id: int) -> Optional[View]: return self._synced_message_views.pop(message_id, None) - def update_from_message(self, message_id: int, components: List[ComponentPayload]) -> None: + def update_from_message(self, message_id: int, components: Sequence[ComponentPayload]) -> None: # pre-req: is_message_tracked == true view = self._synced_message_views[message_id] view.refresh( From 7cd06f0cc07fe452a94a438e14a0285c1c4dc7c2 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 21 Feb 2025 18:41:43 +0100 Subject: [PATCH 021/104] refactor(typing): expand `Message.components` type beyond just `ActionRow`s --- disnake/components.py | 8 ++++++++ disnake/message.py | 25 +++++++++++++------------ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 613cecd9b2..fed13e7e64 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -49,6 +49,7 @@ MediaGalleryComponent as MediaGalleryComponentPayload, MediaGalleryItem as MediaGalleryItemPayload, MentionableSelectMenu as MentionableSelectMenuPayload, + MessageTopLevelComponent as MessageTopLevelComponentPayload, RoleSelectMenu as RoleSelectMenuPayload, SectionComponent as SectionComponentPayload, SelectDefaultValue as SelectDefaultValuePayload, @@ -1194,3 +1195,10 @@ def _component_factory(data: ComponentPayload, *, type: Type[C] = Component) -> else: as_enum = try_enum(ComponentType, component_type) return Component._raw_construct(type=as_enum) # type: ignore + + +# this is just a rebranded _component_factory, +# as a workaround to Python not supporting typescript-like mapped types +# XXX: an alternative would be declaring 14 _component_factory overloads, which also isn't too great. +def _message_component_factory(data: MessageTopLevelComponentPayload) -> MessageTopLevelComponent: + return _component_factory(data) # type: ignore diff --git a/disnake/message.py b/disnake/message.py index 971fcfe012..245a5eec43 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -24,7 +24,10 @@ from . import utils from .channel import PartialMessageable -from .components import ActionRow, ActionRowMessageComponent, _component_factory +from .components import ( + MessageTopLevelComponent, + _message_component_factory, +) from .embeds import Embed from .emoji import Emoji from .enums import ( @@ -60,7 +63,9 @@ from .role import Role from .state import ConnectionState from .threads import AnyThreadArchiveDuration - from .types.components import Component as ComponentPayload + from .types.components import ( + MessageTopLevelComponent as MessageTopLevelComponentPayload, + ) from .types.embed import Embed as EmbedPayload from .types.gateway import ( MessageReactionAddEvent, @@ -1185,9 +1190,8 @@ def __init__( self.stickers: List[StickerItem] = [ StickerItem(data=d, state=state) for d in data.get("sticker_items", []) ] - self.components: List[ActionRow[ActionRowMessageComponent]] = [ - _component_factory(d, type=ActionRow[ActionRowMessageComponent]) - for d in data.get("components", []) + self.components: List[MessageTopLevelComponent] = [ + _message_component_factory(d) for d in data.get("components", []) ] self.poll: Optional[Poll] = None @@ -1433,10 +1437,8 @@ def _handle_mention_roles(self, role_mentions: List[int]) -> None: if role is not None: self.role_mentions.append(role) - def _handle_components(self, components: List[ComponentPayload]) -> None: - self.components = [ - _component_factory(d, type=ActionRow[ActionRowMessageComponent]) for d in components - ] + def _handle_components(self, components: List[MessageTopLevelComponentPayload]) -> None: + self.components = [_message_component_factory(d) for d in components] def _rebind_cached_references(self, new_guild: Guild, new_channel: GuildMessageable) -> None: self.guild = new_guild @@ -2929,9 +2931,8 @@ def __init__( self.stickers: List[StickerItem] = [ StickerItem(data=d, state=state) for d in data.get("sticker_items", []) ] - self.components = [ - _component_factory(d, type=ActionRow[ActionRowMessageComponent]) - for d in data.get("components", []) + self.components: List[MessageTopLevelComponent] = [ + _message_component_factory(d) for d in data.get("components", []) ] self.guild_id = guild_id From 82eb456ee7e3a38796b4b056c7c8e86c717e885a Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 22 Feb 2025 12:24:09 +0100 Subject: [PATCH 022/104] fix: update relevant methods to account for new `Message.components` type --- disnake/components.py | 42 ++++++++++++++++ disnake/interactions/message.py | 19 +++++--- disnake/ui/action_row.py | 51 ++++++++++++------- disnake/ui/view.py | 86 +++++++++++++++------------------ 4 files changed, 129 insertions(+), 69 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index fed13e7e64..c26ae45fb8 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -7,11 +7,15 @@ Any, ClassVar, Dict, + Final, Generic, + Iterator, List, Literal, Mapping, Optional, + Sequence, + Set, Tuple, Type, TypeVar, @@ -1164,6 +1168,44 @@ def accent_color(self) -> Optional[Colour]: return self.accent_colour +# see ActionRowMessageComponent +VALID_ACTION_ROW_MESSAGE_TYPES: Final = ( + Button, + StringSelectMenu, + UserSelectMenu, + RoleSelectMenu, + MentionableSelectMenu, + ChannelSelectMenu, +) + + +def _walk_internal(component: Component, seen: Set[Component]) -> Iterator[Component]: + if component in seen: + # prevent infinite recursion if anyone manages to nest a component in itself + return + seen.add(component) + + yield component + + if isinstance(component, ActionRow): + for item in component.children: + yield from _walk_internal(item, seen) + elif isinstance(component, Section): + yield from _walk_internal(component.accessory, seen) + for item in component.components: + yield from _walk_internal(item, seen) + elif isinstance(component, Container): + for item in component.components: + yield from _walk_internal(item, seen) + + +# yields *all* components recursively +def _walk_all_components(components: Sequence[Component]) -> Iterator[Component]: + seen = set() + for item in components: + yield from _walk_internal(item, seen) + + C = TypeVar("C", bound="Component") diff --git a/disnake/interactions/message.py b/disnake/interactions/message.py index 32fcc47d21..dbc7d0da7c 100644 --- a/disnake/interactions/message.py +++ b/disnake/interactions/message.py @@ -4,7 +4,11 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union -from ..components import ActionRowMessageComponent +from ..components import ( + VALID_ACTION_ROW_MESSAGE_TYPES, + ActionRowMessageComponent, + _walk_all_components, +) from ..enums import ComponentType, try_enum from ..message import Message from ..utils import cached_slot_property @@ -167,11 +171,14 @@ def resolved_values( @cached_slot_property("_cs_component") def component(self) -> ActionRowMessageComponent: - """Union[:class:`Button`, :class:`BaseSelectMenu`]: The component the user interacted with""" - for action_row in self.message.components: - for component in action_row.children: - if component.custom_id == self.data.custom_id: - return component + """Union[:class:`Button`, :class:`BaseSelectMenu`]: The component the user interacted with.""" + # FIXME(3.0?): introduce common base type for components with `custom_id` + for component in _walk_all_components(self.message.components): + if ( + isinstance(component, VALID_ACTION_ROW_MESSAGE_TYPES) + and component.custom_id == self.data.custom_id + ): + return component raise Exception("MessageInteraction is malformed - no component found") # noqa: TRY002 diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index 399d8f1301..d9fbfa099c 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -22,6 +22,7 @@ from ..components import ( ActionRow as ActionRowComponent, ActionRowChildComponent, + ActionRowMessageComponent, Button as ButtonComponent, ChannelSelectMenu as ChannelSelectComponent, MentionableSelectMenu as MentionableSelectComponent, @@ -93,6 +94,26 @@ ) +def _message_component_to_item( + component: ActionRowMessageComponent, +) -> Optional[MessageUIComponent]: + if isinstance(component, ButtonComponent): + return Button.from_component(component) + if isinstance(component, StringSelectComponent): + return StringSelect.from_component(component) + if isinstance(component, UserSelectComponent): + return UserSelect.from_component(component) + if isinstance(component, RoleSelectComponent): + return RoleSelect.from_component(component) + if isinstance(component, MentionableSelectComponent): + return MentionableSelect.from_component(component) + if isinstance(component, ChannelSelectComponent): + return ChannelSelect.from_component(component) + + assert_never(component) + return None + + class ActionRow(Generic[UIComponentT]): """Represents a UI action row. Useful for lower level component manipulation. @@ -794,7 +815,8 @@ def rows_from_message( Raises ------ TypeError - Strict-mode is enabled and an unknown component type is encountered. + Strict-mode is enabled, and an unknown component type is encountered + or message uses v2 components (see also :attr:`.MessageFlags.is_components_v2`). Returns ------- @@ -802,25 +824,20 @@ def rows_from_message( The action rows parsed from the components on the message. """ rows: List[ActionRow[MessageUIComponent]] = [] + # TODO: consolidate the action row checks from here + `View.from_message` + `ViewStore.update_from_message` into one for row in message.components: + if not isinstance(row, ActionRowComponent): + # can happen if message uses components v2 + if strict: + raise TypeError(f"Unexpected top-level component type: {row.type!r}") + continue + rows.append(current_row := ActionRow.with_message_components()) for component in row.children: - if isinstance(component, ButtonComponent): - current_row.append_item(Button.from_component(component)) - elif isinstance(component, StringSelectComponent): - current_row.append_item(StringSelect.from_component(component)) - elif isinstance(component, UserSelectComponent): - current_row.append_item(UserSelect.from_component(component)) - elif isinstance(component, RoleSelectComponent): - current_row.append_item(RoleSelect.from_component(component)) - elif isinstance(component, MentionableSelectComponent): - current_row.append_item(MentionableSelect.from_component(component)) - elif isinstance(component, ChannelSelectComponent): - current_row.append_item(ChannelSelect.from_component(component)) - else: - assert_never(component) - if strict: - raise TypeError(f"Encountered unknown component type: {component.type!r}.") + if item := _message_component_to_item(component): + current_row.append_item(item) + elif strict: + raise TypeError(f"Encountered unknown component type: {component.type!r}.") return rows diff --git a/disnake/ui/view.py b/disnake/ui/view.py index d57df8d1f1..9cc0c3b265 100644 --- a/disnake/ui/view.py +++ b/disnake/ui/view.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging import os import sys import time @@ -14,7 +15,6 @@ Callable, ClassVar, Dict, - Iterator, List, Optional, Sequence, @@ -23,18 +23,15 @@ ) from ..components import ( + VALID_ACTION_ROW_MESSAGE_TYPES, ActionRow as ActionRowComponent, ActionRowMessageComponent, Button as ButtonComponent, - ChannelSelectMenu as ChannelSelectComponent, - MentionableSelectMenu as MentionableSelectComponent, - RoleSelectMenu as RoleSelectComponent, - StringSelectMenu as StringSelectComponent, - UserSelectMenu as UserSelectComponent, _component_factory, + _walk_all_components, ) from ..enums import try_enum_to_int -from ..utils import assert_never +from .action_row import _message_component_to_item from .button import Button from .item import Item @@ -55,41 +52,14 @@ from .item import ItemCallbackType -def _walk_all_components( - components: List[ActionRowComponent[ActionRowMessageComponent]], -) -> Iterator[ActionRowMessageComponent]: - for item in components: - yield from item.children +_log = logging.getLogger(__name__) def _component_to_item(component: ActionRowMessageComponent) -> Item: - if isinstance(component, ButtonComponent): - from .button import Button - - return Button.from_component(component) - if isinstance(component, StringSelectComponent): - from .select import StringSelect - - return StringSelect.from_component(component) - if isinstance(component, UserSelectComponent): - from .select import UserSelect - - return UserSelect.from_component(component) - if isinstance(component, RoleSelectComponent): - from .select import RoleSelect - - return RoleSelect.from_component(component) - if isinstance(component, MentionableSelectComponent): - from .select import MentionableSelect - - return MentionableSelect.from_component(component) - if isinstance(component, ChannelSelectComponent): - from .select import ChannelSelect - - return ChannelSelect.from_component(component) - - assert_never(component) - return Item.from_component(component) + if item := _message_component_to_item(component): + return item + else: + return Item.from_component(component) class _ViewWeights: @@ -251,6 +221,12 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) timeout: Optional[:class:`float`] The timeout of the converted view. + Raises + ------ + TypeError + Message contains v2 components, which are not supported by :class:`View`. + See also :attr:`.MessageFlags.is_components_v2`. + Returns ------- :class:`View` @@ -258,7 +234,15 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) one of its subclasses. """ view = View(timeout=timeout) + # FIXME: preserve rows for component in _walk_all_components(message.components): + if isinstance(component, ActionRowComponent): + continue + elif not isinstance(component, VALID_ACTION_ROW_MESSAGE_TYPES): + # can happen if message uses components v2 + raise TypeError( + f"Cannot construct view from message - unexpected {type(component).__name__}" + ) view.add_item(_component_to_item(component)) return view @@ -428,8 +412,9 @@ def refresh(self, components: List[ActionRowComponent[ActionRowMessageComponent] for item in self.children if item.is_dispatchable() } + children: List[Item] = [] - for component in _walk_all_components(components): + for component in (c for row in components for c in row.children): older: Optional[Item] = None try: older = old_state[(component.type.value, component.custom_id)] # type: ignore @@ -581,9 +566,18 @@ def remove_message_tracking(self, message_id: int) -> Optional[View]: def update_from_message(self, message_id: int, components: Sequence[ComponentPayload]) -> None: # pre-req: is_message_tracked == true view = self._synced_message_views[message_id] - view.refresh( - [ - _component_factory(d, type=ActionRowComponent[ActionRowMessageComponent]) - for d in components - ] - ) + + rows = [ + _component_factory(d, type=ActionRowComponent[ActionRowMessageComponent]) + for d in components + ] + for row in rows: + if not isinstance(row, ActionRowComponent): + _log.warning( + "cannot update view for message %d, unexpected %s", + message_id, + type(row).__name__, + ) + return + + view.refresh(rows) From a7c264833c3792b9093c0475284791328356a16e Mon Sep 17 00:00:00 2001 From: shiftinv Date: Thu, 6 Mar 2025 11:24:51 +0100 Subject: [PATCH 023/104] feat: add `id` field to message/modal interaction data --- disnake/components.py | 7 ------- disnake/interactions/message.py | 7 ++++++- disnake/interactions/modal.py | 2 +- disnake/types/interactions.py | 1 + disnake/ui/action_row.py | 2 +- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index c26ae45fb8..d9a88c0613 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -839,13 +839,6 @@ def to_dict(self) -> TextInputPayload: return payload -# list of more general TODOs: -# TODO: reconsider `components` vs `items` vs `children` -# TODO: deserialize UnfurledMediaItem (?) -# TODO: adopt descriptions from API docs once published -# TODO: document limits - - class Section(Component): """Represents a section from the Discord Bot UI Kit (v2). diff --git a/disnake/interactions/message.py b/disnake/interactions/message.py index dbc7d0da7c..2e5f88528f 100644 --- a/disnake/interactions/message.py +++ b/disnake/interactions/message.py @@ -192,6 +192,10 @@ class MessageInteractionData(Dict[str, Any]): ---------- custom_id: :class:`str` The custom ID of the component. + id: :class:`int` + TODO + + .. versionadded:: 2.11 component_type: :class:`ComponentType` The type of the component. values: Optional[List[:class:`str`]] @@ -203,7 +207,7 @@ class MessageInteractionData(Dict[str, Any]): .. versionadded:: 2.7 """ - __slots__ = ("custom_id", "component_type", "values", "resolved") + __slots__ = ("custom_id", "id", "component_type", "values", "resolved") def __init__( self, @@ -213,6 +217,7 @@ def __init__( ) -> None: super().__init__(data) self.custom_id: str = data["custom_id"] + self.id: int = data.get("id") self.component_type: ComponentType = try_enum(ComponentType, data["component_type"]) self.values: Optional[List[str]] = ( list(map(str, values)) if (values := data.get("values")) else None diff --git a/disnake/interactions/modal.py b/disnake/interactions/modal.py index 7c661ed3dd..149488c0ce 100644 --- a/disnake/interactions/modal.py +++ b/disnake/interactions/modal.py @@ -176,7 +176,7 @@ def __init__(self, *, data: ModalInteractionDataPayload) -> None: super().__init__(data) self.custom_id: str = data["custom_id"] # This uses a stripped-down action row TypedDict, as we only receive - # partial data from the API, generally only containing `type`, `custom_id`, + # partial data from the API, generally only containing `type`, `custom_id`, `id`, # and relevant fields like a select's `values`. self.components: List[ModalInteractionActionRowPayload] = data["components"] diff --git a/disnake/types/interactions.py b/disnake/types/interactions.py index 63b934ffd8..393e0a208f 100644 --- a/disnake/types/interactions.py +++ b/disnake/types/interactions.py @@ -177,6 +177,7 @@ class ApplicationCommandInteractionData(TypedDict): class _BaseComponentInteractionData(TypedDict): custom_id: str + id: int class _BaseSnowflakeComponentInteractionData(_BaseComponentInteractionData): diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index d9fbfa099c..ae7b62eed5 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -195,6 +195,7 @@ def __init__(self, *components: Union[MessageUIComponent, ModalUIComponent]) -> def __repr__(self) -> str: return f"" + # FIXME(3.0)?: `bool(ActionRow())` returns False, which may be undesired def __len__(self) -> int: return len(self._children) @@ -824,7 +825,6 @@ def rows_from_message( The action rows parsed from the components on the message. """ rows: List[ActionRow[MessageUIComponent]] = [] - # TODO: consolidate the action row checks from here + `View.from_message` + `ViewStore.update_from_message` into one for row in message.components: if not isinstance(row, ActionRowComponent): # can happen if message uses components v2 From 891ca40dee17ab0fda3e8932c01345c59e4f41ad Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 8 Mar 2025 13:16:16 +0100 Subject: [PATCH 024/104] refactor: introduce yet another ui base type, for non-actionrow-child components --- disnake/ui/action_row.py | 1 + disnake/ui/item.py | 68 ++++++++++++++++++++++++++++++---------- disnake/ui/view.py | 8 +---- docs/api/ui.rst | 8 +++++ docs/conf.py | 2 +- 5 files changed, 63 insertions(+), 24 deletions(-) diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index ae7b62eed5..303f163519 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -114,6 +114,7 @@ def _message_component_to_item( return None +# TODO: this can likely also subclass the new `UIComponent` base type class ActionRow(Generic[UIComponentT]): """Represents a UI action row. Useful for lower level component manipulation. diff --git a/disnake/ui/item.py b/disnake/ui/item.py index 9aea20d7d1..0eb98b1289 100644 --- a/disnake/ui/item.py +++ b/disnake/ui/item.py @@ -8,6 +8,7 @@ Any, Callable, Coroutine, + Dict, Generic, Optional, Protocol, @@ -17,7 +18,11 @@ overload, ) -__all__ = ("Item", "WrappedComponent") +__all__ = ( + "UIComponent", + "WrappedComponent", + "Item", +) I = TypeVar("I", bound="Item[Any]") V_co = TypeVar("V_co", bound="Optional[View]", covariant=True) @@ -26,21 +31,18 @@ from typing_extensions import Self from ..client import Client - from ..components import ActionRowChildComponent + from ..components import ActionRowChildComponent, Component from ..enums import ComponentType from ..interactions import MessageInteraction - from ..types.components import Component as ComponentPayload + from ..types.components import ActionRowChildComponent as ActionRowChildComponentPayload from .view import View ItemCallbackType = Callable[[V_co, I, MessageInteraction], Coroutine[Any, Any, Any]] -else: - ParamSpec = TypeVar - ClientT = TypeVar("ClientT", bound="Client") -class WrappedComponent(ABC): +class UIComponent(ABC): """Represents the base UI component that all UI components inherit from. The following classes implement this ABC: @@ -48,19 +50,22 @@ class WrappedComponent(ABC): - :class:`disnake.ui.Button` - subtypes of :class:`disnake.ui.BaseSelect` (:class:`disnake.ui.ChannelSelect`, :class:`disnake.ui.MentionableSelect`, :class:`disnake.ui.RoleSelect`, :class:`disnake.ui.StringSelect`, :class:`disnake.ui.UserSelect`) - :class:`disnake.ui.TextInput` - - .. versionadded:: 2.4 + - :class:`disnake.ui.Section` + - :class:`disnake.ui.TextDisplay` + - :class:`disnake.ui.Thumbnail` + - :class:`disnake.ui.MediaGallery` + - :class:`disnake.ui.File` + - :class:`disnake.ui.Separator` + - :class:`disnake.ui.Container` + + .. versionadded:: 2.11 """ __repr_attributes__: Tuple[str, ...] @property @abstractmethod - def _underlying(self) -> ActionRowChildComponent: ... - - @property - @abstractmethod - def width(self) -> int: ... + def _underlying(self) -> Component: ... def __repr__(self) -> str: attrs = " ".join(f"{key}={getattr(self, key)!r}" for key in self.__repr_attributes__) @@ -70,12 +75,43 @@ def __repr__(self) -> str: def type(self) -> ComponentType: return self._underlying.type - def to_component_dict(self) -> ComponentPayload: + def to_component_dict(self) -> Dict[str, Any]: + return self._underlying.to_dict() + + +# Essentially the same as the base `UIComponent`, with the addition of `width`. +class WrappedComponent(UIComponent): + """Represents the base UI component that all :class:`ActionRow`\\-compatible + UI components inherit from. + + This class adds more functionality on top of the :class:`UIComponent` base class, + specifically for action rows. + + The following classes implement this ABC: + + - :class:`disnake.ui.Button` + - subtypes of :class:`disnake.ui.BaseSelect` (:class:`disnake.ui.ChannelSelect`, :class:`disnake.ui.MentionableSelect`, :class:`disnake.ui.RoleSelect`, :class:`disnake.ui.StringSelect`, :class:`disnake.ui.UserSelect`) + - :class:`disnake.ui.TextInput` + + .. versionadded:: 2.4 + """ + + # the purpose of these two is just more precise typechecking compared to the base type + + @property + @abstractmethod + def _underlying(self) -> ActionRowChildComponent: ... + + def to_component_dict(self) -> ActionRowChildComponentPayload: return self._underlying.to_dict() + @property + @abstractmethod + def width(self) -> int: ... + class Item(WrappedComponent, Generic[V_co]): - """Represents the base UI item that all UI items inherit from. + """Represents the base UI item that all interactive UI items inherit from. This class adds more functionality on top of the :class:`WrappedComponent` base class. This functionality mostly relates to :class:`disnake.ui.View`. diff --git a/disnake/ui/view.py b/disnake/ui/view.py index 9cc0c3b265..cdbf330376 100644 --- a/disnake/ui/view.py +++ b/disnake/ui/view.py @@ -19,7 +19,6 @@ Optional, Sequence, Tuple, - cast, ) from ..components import ( @@ -46,7 +45,6 @@ from ..state import ConnectionState from ..types.components import ( ActionRow as ActionRowPayload, - ActionRowChildComponent as ActionRowChildComponentPayload, Component as ComponentPayload, ) from .item import ItemCallbackType @@ -188,11 +186,7 @@ def key(item: Item) -> int: children = sorted(self.children, key=key) components: List[ActionRowPayload] = [] for _, group in groupby(children, key=key): - # these will always be valid actionrow child components - children = cast( - "List[ActionRowChildComponentPayload]", - [item.to_component_dict() for item in group], - ) + children = [item.to_component_dict() for item in group] if not children: continue diff --git a/docs/api/ui.rst b/docs/api/ui.rst index 725c65fb77..ea1efaffc4 100644 --- a/docs/api/ui.rst +++ b/docs/api/ui.rst @@ -45,6 +45,14 @@ WrappedComponent .. autoclass:: WrappedComponent :members: +UIComponent +~~~~~~~~~~~ + +.. attributetable:: UIComponent + +.. autoclass:: UIComponent + :members: + Button ~~~~~~ diff --git a/docs/conf.py b/docs/conf.py index 99e6fa6c9e..fe65c28491 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -78,7 +78,7 @@ .. |coro| replace:: This function is a |coroutine_link|_. .. |maybecoro| replace:: This function *could be a* |coroutine_link|_. .. |coroutine_link| replace:: *coroutine* -.. |components_type| replace:: Union[:class:`disnake.ui.ActionRow`, :class:`disnake.ui.WrappedComponent`, List[Union[:class:`disnake.ui.ActionRow`, :class:`disnake.ui.WrappedComponent`, List[:class:`disnake.ui.WrappedComponent`]]]] +.. |components_type| replace:: Union[:class:`disnake.ui.ActionRow`, :class:`disnake.ui.UIComponent`, List[Union[:class:`disnake.ui.ActionRow`, :class:`disnake.ui.UIComponent`, List[:class:`disnake.ui.WrappedComponent`]]]] .. |resource_type| replace:: Union[:class:`bytes`, :class:`.Asset`, :class:`.Emoji`, :class:`.PartialEmoji`, :class:`.StickerItem`, :class:`.Sticker`] .. _coroutine_link: https://docs.python.org/3/library/asyncio-task.html#coroutine """ From bd2865a1cfecb3fe41087aac9fceb06af2b595f1 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 15 Mar 2025 11:48:48 +0100 Subject: [PATCH 025/104] feat(ui): add new ui components, still partially broken --- disnake/components.py | 3 +- disnake/ui/__init__.py | 7 +++ disnake/ui/button.py | 2 +- disnake/ui/container.py | 103 ++++++++++++++++++++++++++++++++++++ disnake/ui/file.py | 63 ++++++++++++++++++++++ disnake/ui/media_gallery.py | 44 +++++++++++++++ disnake/ui/section.py | 76 ++++++++++++++++++++++++++ disnake/ui/select/base.py | 2 +- disnake/ui/separator.py | 65 +++++++++++++++++++++++ disnake/ui/text_display.py | 45 ++++++++++++++++ disnake/ui/text_input.py | 2 +- disnake/ui/thumbnail.py | 79 +++++++++++++++++++++++++++ 12 files changed, 487 insertions(+), 4 deletions(-) create mode 100644 disnake/ui/container.py create mode 100644 disnake/ui/file.py create mode 100644 disnake/ui/media_gallery.py create mode 100644 disnake/ui/section.py create mode 100644 disnake/ui/separator.py create mode 100644 disnake/ui/text_display.py create mode 100644 disnake/ui/thumbnail.py diff --git a/disnake/components.py b/disnake/components.py index d9a88c0613..b45f1c20a1 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -112,6 +112,7 @@ # any child component type of action rows ActionRowChildComponent = Union[ActionRowMessageComponent, ActionRowModalComponent] +# TODO: this might have to be covariant ActionRowChildComponentT = TypeVar("ActionRowChildComponentT", bound=ActionRowChildComponent) # valid `Section.accessory` types @@ -1020,7 +1021,7 @@ def __repr__(self) -> str: return f"" -# TODO: temporary name to avoid shadowing `disnake.file.File` +# TODO: temporary(?) name to avoid shadowing `disnake.file.File` class FileComponent(Component): """Represents a file component from the Discord Bot UI Kit (v2). diff --git a/disnake/ui/__init__.py b/disnake/ui/__init__.py index 90dfa57aaa..fa83f33f6c 100644 --- a/disnake/ui/__init__.py +++ b/disnake/ui/__init__.py @@ -12,8 +12,15 @@ from .action_row import * from .button import * +from .container import * +from .file import * from .item import * +from .media_gallery import * from .modal import * +from .section import * from .select import * +from .separator import * +from .text_display import * from .text_input import * +from .thumbnail import * from .view import * diff --git a/disnake/ui/button.py b/disnake/ui/button.py index dd9919cbdd..d1134b8e45 100644 --- a/disnake/ui/button.py +++ b/disnake/ui/button.py @@ -84,7 +84,7 @@ class Button(Item[V_co]): "sku_id", "row", ) - # We have to set this to MISSING in order to overwrite the abstract property from WrappedComponent + # We have to set this to MISSING in order to overwrite the abstract property from UIComponent _underlying: ButtonComponent = MISSING @overload diff --git a/disnake/ui/container.py b/disnake/ui/container.py new file mode 100644 index 0000000000..7eb076e121 --- /dev/null +++ b/disnake/ui/container.py @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional, Sequence, Tuple, Union + +from ..colour import Colour +from ..components import Container as ContainerComponent +from ..enums import ComponentType +from ..utils import MISSING +from .item import UIComponent + +if TYPE_CHECKING: + from .action_row import ActionRow, MessageUIComponent + from .file import File + from .media_gallery import MediaGallery + from .section import Section + from .separator import Separator + from .text_display import TextDisplay + + ContainerChildUIComponent = Union[ + ActionRow[MessageUIComponent], + Section, + TextDisplay, + MediaGallery, + File, + Separator, + ] + +__all__ = ("Container",) + + +class Container(UIComponent): + """Represents a UI container. + + This is visually similar to :class:`Embed`\\s, and contains other components. + + .. versionadded:: 2.11 + + Parameters + ---------- + *components: Union[:class:`~ui.ActionRow`, :class:`~ui.Section`, :class:`~ui.TextDisplay`, :class:`~ui.MediaGallery`, :class:`~ui.FileComponent`, :class:`~ui.Separator`] + The components in this container. + accent_colour: Optional[:class:`Colour`] + The accent colour of the container. + spoiler: :class:`bool` + Whether the container is marked as a spoiler. Defaults to ``False``. + """ + + __repr_attributes__: Tuple[str, ...] = ( + "components", + "accent_colour", + "spoiler", + ) + # We have to set this to MISSING in order to overwrite the abstract property from UIComponent + _underlying: ContainerComponent = MISSING + + def __init__( + self, + *components: ContainerChildUIComponent, + accent_colour: Optional[Colour] = None, + spoiler: bool = False, + ) -> None: + # TODO: this also just doesn't work this way + self._underlying = ContainerComponent._raw_construct( + type=ComponentType.container, + components=list(components), + _accent_colour=accent_colour.value if accent_colour is not None else None, + spoiler=spoiler, + ) + + @property + def components(self) -> Sequence[ContainerChildUIComponent]: + """Sequence[Union[:class:`~ui.ActionRow`, :class:`~ui.Section`, :class:`~ui.TextDisplay`, :class:`~ui.MediaGallery`, :class:`~ui.FileComponent`, :class:`~ui.Separator`]]: The components in this container.""" + # TODO: SequenceProxy? + return self._underlying.components + + @components.setter + def components(self, values: Sequence[ContainerChildUIComponent]) -> None: + # don't be too restrictive for easier future compatibility + for value in values: + if not isinstance(value, UIComponent): + raise TypeError("TODO") + self._underlying.components = values + + # FIXME: add accent_color + @property + def accent_colour(self) -> Optional[Colour]: + """Optional[:class:`Colour`]: The accent colour of the container.""" + return self._underlying.accent_colour + + @accent_colour.setter + def accent_colour(self, value: Optional[Colour]) -> None: + self._underlying._accent_colour = value.value if value is not None else None + + @property + def spoiler(self) -> bool: + """:class:`bool`: Whether the container is marked as a spoiler.""" + return self._underlying.spoiler + + @spoiler.setter + def spoiler(self, value: bool) -> None: + self._underlying.spoiler = value diff --git a/disnake/ui/file.py b/disnake/ui/file.py new file mode 100644 index 0000000000..dedca9ba98 --- /dev/null +++ b/disnake/ui/file.py @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from typing import Any, Tuple + +from ..components import FileComponent +from ..enums import ComponentType +from ..utils import MISSING +from .item import UIComponent + +__all__ = ("File",) + + +class File(UIComponent): + """Represents a UI file component. + + .. versionadded:: 2.11 + + Parameters + ---------- + file: Any + n/a + spoiler: :class:`bool` + Whether the file is marked as a spoiler. Defaults to ``False``. + """ + + __repr_attributes__: Tuple[str, ...] = ( + "file", + "spoiler", + ) + # We have to set this to MISSING in order to overwrite the abstract property from UIComponent + _underlying: FileComponent = MISSING + + def __init__( + self, + *, + file: Any, # XXX: positional? + spoiler: bool = False, + ) -> None: + self._underlying = FileComponent._raw_construct( + type=ComponentType.file, + file=file, + spoiler=spoiler, + ) + + @property + def file(self) -> Any: + """Any: n/a""" + return self._underlying.file + + @file.setter + def file(self, value: Any) -> None: + self._underlying.file = value + + @property + def spoiler(self) -> bool: + """:class:`bool`: Whether the file is marked as a spoiler.""" + return self._underlying.spoiler + + @spoiler.setter + def spoiler(self, value: bool) -> None: + self._underlying.spoiler = value diff --git a/disnake/ui/media_gallery.py b/disnake/ui/media_gallery.py new file mode 100644 index 0000000000..63bb728169 --- /dev/null +++ b/disnake/ui/media_gallery.py @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from typing import List, Sequence, Tuple + +from ..components import MediaGallery as MediaGalleryComponent, MediaGalleryItem +from ..enums import ComponentType +from ..utils import MISSING +from .item import UIComponent + +__all__ = ("MediaGallery",) + + +class MediaGallery(UIComponent): + """Represents a UI media gallery. + + .. versionadded:: 2.11 + + Parameters + ---------- + *items: :class:`MediaGalleryItem` + The list of images in this gallery. + """ + + __repr_attributes__: Tuple[str, ...] = ("items",) + # We have to set this to MISSING in order to overwrite the abstract property from UIComponent + _underlying: MediaGalleryComponent = MISSING + + # FIXME: MediaGalleryItem currently isn't user-instantiable + def __init__(self, *items: Sequence[MediaGalleryItem]) -> None: + self._underlying = MediaGalleryComponent._raw_construct( + type=ComponentType.media_gallery, + items=list(items), + ) + + @property + def items(self) -> List[MediaGalleryItem]: + """List[:class:`MediaGalleryItem`]: The images in this gallery.""" + return self._underlying.items + + @items.setter + def items(self, values: Sequence[MediaGalleryItem]) -> None: + self._underlying.items = list(values) diff --git a/disnake/ui/section.py b/disnake/ui/section.py new file mode 100644 index 0000000000..93cb660f56 --- /dev/null +++ b/disnake/ui/section.py @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from typing import TYPE_CHECKING, Sequence, Tuple, Union + +from ..components import Section as SectionComponent +from ..enums import ComponentType +from ..utils import MISSING +from .button import Button +from .item import UIComponent + +if TYPE_CHECKING: + from .text_display import TextDisplay + from .thumbnail import Thumbnail + +__all__ = ("Section",) + + +class Section(UIComponent): + """Represents a UI section. + + .. versionadded:: 2.11 + + Parameters + ---------- + *components: :class:`~ui.TextDisplay` + The list of text items in this section. + accessory: Union[:class:`~ui.Thumbnail`, :class:`~ui.Button`] + The accessory component displayed next to the section text. + """ + + __repr_attributes__: Tuple[str, ...] = ( + "components", + "accessory", + ) + # We have to set this to MISSING in order to overwrite the abstract property from UIComponent + _underlying: SectionComponent = MISSING + + def __init__( + self, + *components: TextDisplay, + accessory: Union[Thumbnail, Button], + ) -> None: + # TODO: this just doesn't work this way + self._underlying = SectionComponent._raw_construct( + type=ComponentType.section, + components=list(components), + accessory=accessory, + ) + + @property + def components(self) -> Sequence[TextDisplay]: + """Sequence[:class:`~ui.TextDisplay`]: The text items in this section.""" + # TODO: SequenceProxy? + return self._underlying.components + + @components.setter + def components(self, values: Sequence[TextDisplay]) -> None: + # don't be too restrictive for easier future compatibility + for value in values: + if not isinstance(value, UIComponent): + raise TypeError("TODO") + self._underlying.components = values + + @property + def accessory(self) -> Union[Thumbnail, Button]: + """Union[:class:`~ui.Thumbnail`, :class:`~ui.Button`]: The accessory component displayed next to the section text.""" + return self._underlying.accessory + + @accessory.setter + def accessory(self, value: Union[Thumbnail, Button]) -> None: + # don't be too restrictive for easier future compatibility + if not isinstance(value, UIComponent): + raise TypeError("TODO") + self._underlying.accessory = value diff --git a/disnake/ui/select/base.py b/disnake/ui/select/base.py index 10cae4f4c9..b7897bedae 100644 --- a/disnake/ui/select/base.py +++ b/disnake/ui/select/base.py @@ -73,7 +73,7 @@ class BaseSelect(Generic[SelectMenuT, SelectValueT, V_co], Item[V_co], ABC): "max_values", "disabled", ) - # We have to set this to MISSING in order to overwrite the abstract property from WrappedComponent + # We have to set this to MISSING in order to overwrite the abstract property from UIComponent _underlying: SelectMenuT = MISSING # Subclasses are expected to set this diff --git a/disnake/ui/separator.py b/disnake/ui/separator.py new file mode 100644 index 0000000000..dad9b59898 --- /dev/null +++ b/disnake/ui/separator.py @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from typing import Tuple + +from ..components import Separator as SeparatorComponent +from ..enums import ComponentType, SeparatorSpacingSize +from ..utils import MISSING +from .item import UIComponent + +__all__ = ("Separator",) + + +class Separator(UIComponent): + """Represents a UI separator. + + .. versionadded:: 2.11 + + Parameters + ---------- + divider: :class:`bool` + Whether the separator should be visible, instead of just being vertical padding/spacing. + Defaults to ``True``. + spacing: :class:`SeparatorSpacingSize` + The size of the separator. + Defaults to :attr:`~SeparatorSpacingSize.small`. + """ + + __repr_attributes__: Tuple[str, ...] = ( + "divider", + "spacing", + ) + # We have to set this to MISSING in order to overwrite the abstract property from UIComponent + _underlying: SeparatorComponent = MISSING + + def __init__( + self, + *, + divider: bool = True, + spacing: SeparatorSpacingSize = SeparatorSpacingSize.small, + ) -> None: + self._underlying = SeparatorComponent._raw_construct( + type=ComponentType.separator, + divider=divider, + spacing=spacing, + ) + + @property + def divider(self) -> bool: + """:class:`bool`: Whether the separator should be visible, instead of just being vertical padding/spacing.""" + return self._underlying.divider + + @divider.setter + def divider(self, value: bool) -> None: + self._underlying.divider = value + + @property + def spacing(self) -> SeparatorSpacingSize: + """:class:`SeparatorSpacingSize`: The size of the separator.""" + return self._underlying.spacing + + @spacing.setter + def spacing(self, value: SeparatorSpacingSize) -> None: + self._underlying.spacing = value diff --git a/disnake/ui/text_display.py b/disnake/ui/text_display.py new file mode 100644 index 0000000000..ccd6f38424 --- /dev/null +++ b/disnake/ui/text_display.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from typing import Tuple + +from ..components import TextDisplay as TextDisplayComponent +from ..enums import ComponentType +from ..utils import MISSING +from .item import UIComponent + +__all__ = ("TextDisplay",) + + +# XXX: `TextDisplay` vs just `Text` +class TextDisplay(UIComponent): + """Represents a UI text display. + + .. versionadded:: 2.11 + + Parameters + ---------- + content: :class:`str` + The text displayed by this component. + """ + + __repr_attributes__: Tuple[str, ...] = ("content",) + # We have to set this to MISSING in order to overwrite the abstract property from UIComponent + _underlying: TextDisplayComponent = MISSING + + def __init__(self, content: str) -> None: + self._underlying = TextDisplayComponent._raw_construct( + type=ComponentType.text_display, + content=content, + ) + + @property + def content(self) -> str: + """:class:`str`: The text displayed by this component.""" + return self._underlying.content + + @content.setter + def content(self, value: str) -> None: + # TODO: consider str cast? + self._underlying.content = value diff --git a/disnake/ui/text_input.py b/disnake/ui/text_input.py index 6c371a4760..81a88e0fe6 100644 --- a/disnake/ui/text_input.py +++ b/disnake/ui/text_input.py @@ -49,7 +49,7 @@ class TextInput(WrappedComponent): "min_length", "max_length", ) - # We have to set this to MISSING in order to overwrite the abstract property from WrappedComponent + # We have to set this to MISSING in order to overwrite the abstract property from UIComponent _underlying: TextInputComponent = MISSING def __init__( diff --git a/disnake/ui/thumbnail.py b/disnake/ui/thumbnail.py new file mode 100644 index 0000000000..17b50b0b13 --- /dev/null +++ b/disnake/ui/thumbnail.py @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from typing import Any, Optional, Tuple + +from ..components import Thumbnail as ThumbnailComponent +from ..enums import ComponentType +from ..utils import MISSING +from .item import UIComponent + +__all__ = ("Thumbnail",) + + +class Thumbnail(UIComponent): + """Represents a UI thumbnail. + + This is only supported as the :attr:`~ui.Section.accessory` of a section component. + + .. versionadded:: 2.11 + + Parameters + ---------- + media: Any + n/a + description: Optional[:class:`str`] + The thumbnail's description ("alt text"), if any. + spoiler: :class:`bool` + Whether the thumbnail is marked as a spoiler. Defaults to ``False``. + """ + + __repr_attributes__: Tuple[str, ...] = ( + "media", + "description", + "spoiler", + ) + # We have to set this to MISSING in order to overwrite the abstract property from UIComponent + _underlying: ThumbnailComponent = MISSING + + def __init__( + self, + *, + media: Any, # XXX: positional? + description: Optional[str] = None, + spoiler: bool = False, + ) -> None: + self._underlying = ThumbnailComponent._raw_construct( + type=ComponentType.thumbnail, + media=media, + description=description, + spoiler=spoiler, + ) + + @property + def media(self) -> Any: + """Any: n/a""" + return self._underlying.media + + @media.setter + def media(self, value: Any) -> None: + self._underlying.media = value + + @property + def description(self) -> Optional[str]: + """Optional[:class:`str`]: The thumbnail's description ("alt text"), if any.""" + return self._underlying.description + + @description.setter + def description(self, value: Optional[str]) -> None: + self._underlying.description = value + + @property + def spoiler(self) -> bool: + """:class:`bool`: Whether the thumbnail is marked as a spoiler.""" + return self._underlying.spoiler + + @spoiler.setter + def spoiler(self, value: bool) -> None: + self._underlying.spoiler = value From 222f513a9970dd4cd1a87bfe05c0c93ddc755621 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 15 Mar 2025 12:23:41 +0100 Subject: [PATCH 026/104] refactor(ui): rework section and container to actually be usable --- disnake/ui/container.py | 76 +++++++++++++++++++---------------------- disnake/ui/item.py | 7 ++++ disnake/ui/section.py | 57 +++++++++++++++---------------- 3 files changed, 70 insertions(+), 70 deletions(-) diff --git a/disnake/ui/container.py b/disnake/ui/container.py index 7eb076e121..1b79862406 100644 --- a/disnake/ui/container.py +++ b/disnake/ui/container.py @@ -2,13 +2,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, Union from ..colour import Colour from ..components import Container as ContainerComponent from ..enums import ComponentType -from ..utils import MISSING -from .item import UIComponent +from ..utils import SequenceProxy +from .item import UIComponent, ensure_ui_component if TYPE_CHECKING: from .action_row import ActionRow, MessageUIComponent @@ -45,59 +45,55 @@ class Container(UIComponent): The accent colour of the container. spoiler: :class:`bool` Whether the container is marked as a spoiler. Defaults to ``False``. + + Attributes + ---------- + accent_colour: Optional[:class:`Colour`] + The accent colour of the container. + spoiler: :class:`bool` + Whether the container is marked as a spoiler. """ + # unused, but technically required by base type __repr_attributes__: Tuple[str, ...] = ( "components", "accent_colour", "spoiler", ) - # We have to set this to MISSING in order to overwrite the abstract property from UIComponent - _underlying: ContainerComponent = MISSING + # TODO: consider providing sequence operations (append, insert, remove, etc.) def __init__( self, *components: ContainerChildUIComponent, accent_colour: Optional[Colour] = None, spoiler: bool = False, ) -> None: - # TODO: this also just doesn't work this way - self._underlying = ContainerComponent._raw_construct( - type=ComponentType.container, - components=list(components), - _accent_colour=accent_colour.value if accent_colour is not None else None, - spoiler=spoiler, - ) - + self._components: List[ContainerChildUIComponent] = [ + # FIXME: typing broken until action rows become UIComponents + ensure_ui_component(c, "components") + for c in components + ] + # FIXME: add accent_color + self.accent_colour: Optional[Colour] = accent_colour + self.spoiler: bool = spoiler + + # TODO: consider moving runtime checks from constructor into property setters, also making these fields writable @property def components(self) -> Sequence[ContainerChildUIComponent]: - """Sequence[Union[:class:`~ui.ActionRow`, :class:`~ui.Section`, :class:`~ui.TextDisplay`, :class:`~ui.MediaGallery`, :class:`~ui.FileComponent`, :class:`~ui.Separator`]]: The components in this container.""" - # TODO: SequenceProxy? - return self._underlying.components - - @components.setter - def components(self, values: Sequence[ContainerChildUIComponent]) -> None: - # don't be too restrictive for easier future compatibility - for value in values: - if not isinstance(value, UIComponent): - raise TypeError("TODO") - self._underlying.components = values - - # FIXME: add accent_color - @property - def accent_colour(self) -> Optional[Colour]: - """Optional[:class:`Colour`]: The accent colour of the container.""" - return self._underlying.accent_colour + """Sequence[Union[:class:`~ui.ActionRow`, :class:`~ui.Section`, :class:`~ui.TextDisplay`, :class:`~ui.MediaGallery`, :class:`~ui.FileComponent`, :class:`~ui.Separator`]]: + A read-only copy of the components in this container. + """ + return SequenceProxy(self._components) - @accent_colour.setter - def accent_colour(self, value: Optional[Colour]) -> None: - self._underlying._accent_colour = value.value if value is not None else None + def __repr__(self) -> str: + # implemented separately for now, due to SequenceProxy repr + return f"" @property - def spoiler(self) -> bool: - """:class:`bool`: Whether the container is marked as a spoiler.""" - return self._underlying.spoiler - - @spoiler.setter - def spoiler(self, value: bool) -> None: - self._underlying.spoiler = value + def _underlying(self) -> ContainerComponent: + return ContainerComponent._raw_construct( + type=ComponentType.container, + components=[comp._underlying for comp in self._components], + _accent_colour=self.accent_colour.value if self.accent_colour is not None else None, + spoiler=self.spoiler, + ) diff --git a/disnake/ui/item.py b/disnake/ui/item.py index 0eb98b1289..a926ce79c8 100644 --- a/disnake/ui/item.py +++ b/disnake/ui/item.py @@ -40,6 +40,13 @@ ItemCallbackType = Callable[[V_co, I, MessageInteraction], Coroutine[Any, Any, Any]] ClientT = TypeVar("ClientT", bound="Client") +UIComponentT = TypeVar("UIComponentT", bound="UIComponent") + + +def ensure_ui_component(obj: UIComponentT, name: str) -> UIComponentT: + if not isinstance(obj, UIComponent): + raise TypeError(f"{name} should be a valid UI component, got {type(obj).__name__}.") + return obj class UIComponent(ABC): diff --git a/disnake/ui/section.py b/disnake/ui/section.py index 93cb660f56..be15a96de9 100644 --- a/disnake/ui/section.py +++ b/disnake/ui/section.py @@ -2,15 +2,15 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Sequence, Tuple, Union +from typing import TYPE_CHECKING, List, Sequence, Tuple, Union from ..components import Section as SectionComponent from ..enums import ComponentType -from ..utils import MISSING -from .button import Button -from .item import UIComponent +from ..utils import SequenceProxy +from .item import UIComponent, ensure_ui_component if TYPE_CHECKING: + from .button import Button from .text_display import TextDisplay from .thumbnail import Thumbnail @@ -30,47 +30,44 @@ class Section(UIComponent): The accessory component displayed next to the section text. """ + # unused, but technically required by base type __repr_attributes__: Tuple[str, ...] = ( "components", "accessory", ) - # We have to set this to MISSING in order to overwrite the abstract property from UIComponent - _underlying: SectionComponent = MISSING + # TODO: consider providing sequence operations (append, insert, remove, etc.) def __init__( self, *components: TextDisplay, accessory: Union[Thumbnail, Button], ) -> None: - # TODO: this just doesn't work this way - self._underlying = SectionComponent._raw_construct( - type=ComponentType.section, - components=list(components), - accessory=accessory, - ) + self._components: List[TextDisplay] = [ + ensure_ui_component(c, "components") for c in components + ] + self._accessory: Union[Thumbnail, Button] = ensure_ui_component(accessory, "accessory") + # TODO: consider moving runtime checks from constructor into property setters, also making these fields writable @property def components(self) -> Sequence[TextDisplay]: - """Sequence[:class:`~ui.TextDisplay`]: The text items in this section.""" - # TODO: SequenceProxy? - return self._underlying.components - - @components.setter - def components(self, values: Sequence[TextDisplay]) -> None: - # don't be too restrictive for easier future compatibility - for value in values: - if not isinstance(value, UIComponent): - raise TypeError("TODO") - self._underlying.components = values + """Sequence[:class:`~ui.TextDisplay`]: + A read-only copy of the text items in this section. + """ + return SequenceProxy(self._components) @property def accessory(self) -> Union[Thumbnail, Button]: """Union[:class:`~ui.Thumbnail`, :class:`~ui.Button`]: The accessory component displayed next to the section text.""" - return self._underlying.accessory + return self._accessory + + def __repr__(self) -> str: + # implemented separately for now, due to SequenceProxy repr + return f"
" - @accessory.setter - def accessory(self, value: Union[Thumbnail, Button]) -> None: - # don't be too restrictive for easier future compatibility - if not isinstance(value, UIComponent): - raise TypeError("TODO") - self._underlying.accessory = value + @property + def _underlying(self) -> SectionComponent: + return SectionComponent._raw_construct( + type=ComponentType.section, + components=[comp._underlying for comp in self._components], + accessory=self._accessory._underlying, + ) From 962c5d74a856d84831221caf051c91581587b0d7 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 15 Mar 2025 12:52:10 +0100 Subject: [PATCH 027/104] docs: add ui components to docs, fix references --- disnake/abc.py | 8 ++--- disnake/channel.py | 4 +-- disnake/ui/container.py | 12 +++---- disnake/ui/media_gallery.py | 4 +-- disnake/ui/section.py | 10 +++--- disnake/ui/separator.py | 6 ++-- disnake/ui/thumbnail.py | 2 +- docs/api/ui.rst | 63 +++++++++++++++++++++++++++++++++++++ 8 files changed, 84 insertions(+), 25 deletions(-) diff --git a/disnake/abc.py b/disnake/abc.py index 85c63dd592..a77b07d40b 100644 --- a/disnake/abc.py +++ b/disnake/abc.py @@ -1531,8 +1531,8 @@ async def send( ``stickers``, ``components``, ``poll`` or ``view`` must be provided. To upload a single file, the ``file`` parameter should be used with a - single :class:`.File` object. To upload multiple files, the ``files`` - parameter should be used with a :class:`list` of :class:`.File` objects. + single :class:`~disnake.File` object. To upload multiple files, the ``files`` + parameter should be used with a :class:`list` of :class:`~disnake.File` objects. **Specifying both parameters will lead to an exception**. To upload a single embed, the ``embed`` parameter should be used with a @@ -1558,9 +1558,9 @@ async def send( .. versionadded:: 2.0 - file: :class:`.File` + file: :class:`~disnake.File` The file to upload. This cannot be mixed with the ``files`` parameter. - files: List[:class:`.File`] + files: List[:class:`~disnake.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:`.StandardSticker`, :class:`.StickerItem`]] diff --git a/disnake/channel.py b/disnake/channel.py index 800d618001..695e832bd5 100644 --- a/disnake/channel.py +++ b/disnake/channel.py @@ -3625,9 +3625,9 @@ async def create_thread( .. versionadded:: 2.9 - file: :class:`.File` + file: :class:`~disnake.File` The file to upload. This cannot be mixed with the ``files`` parameter. - files: List[:class:`.File`] + files: List[:class:`~disnake.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:`.StandardSticker`, :class:`.StickerItem`]] diff --git a/disnake/ui/container.py b/disnake/ui/container.py index 1b79862406..5a319d1457 100644 --- a/disnake/ui/container.py +++ b/disnake/ui/container.py @@ -33,22 +33,22 @@ class Container(UIComponent): """Represents a UI container. - This is visually similar to :class:`Embed`\\s, and contains other components. + This is visually similar to :class:`.Embed`\\s, and contains other components. .. versionadded:: 2.11 Parameters ---------- - *components: Union[:class:`~ui.ActionRow`, :class:`~ui.Section`, :class:`~ui.TextDisplay`, :class:`~ui.MediaGallery`, :class:`~ui.FileComponent`, :class:`~ui.Separator`] + *components: Union[:class:`~.ui.ActionRow`, :class:`~.ui.Section`, :class:`~.ui.TextDisplay`, :class:`~.ui.MediaGallery`, :class:`~.ui.File`, :class:`~.ui.Separator`] The components in this container. - accent_colour: Optional[:class:`Colour`] + accent_colour: Optional[:class:`.Colour`] The accent colour of the container. spoiler: :class:`bool` Whether the container is marked as a spoiler. Defaults to ``False``. Attributes ---------- - accent_colour: Optional[:class:`Colour`] + accent_colour: Optional[:class:`.Colour`] The accent colour of the container. spoiler: :class:`bool` Whether the container is marked as a spoiler. @@ -80,9 +80,7 @@ def __init__( # TODO: consider moving runtime checks from constructor into property setters, also making these fields writable @property def components(self) -> Sequence[ContainerChildUIComponent]: - """Sequence[Union[:class:`~ui.ActionRow`, :class:`~ui.Section`, :class:`~ui.TextDisplay`, :class:`~ui.MediaGallery`, :class:`~ui.FileComponent`, :class:`~ui.Separator`]]: - A read-only copy of the components in this container. - """ + """Sequence[Union[:class:`~.ui.ActionRow`, :class:`~.ui.Section`, :class:`~.ui.TextDisplay`, :class:`~.ui.MediaGallery`, :class:`~.ui.File`, :class:`~.ui.Separator`]]: A read-only copy of the components in this container.""" return SequenceProxy(self._components) def __repr__(self) -> str: diff --git a/disnake/ui/media_gallery.py b/disnake/ui/media_gallery.py index 63bb728169..eb0f709a83 100644 --- a/disnake/ui/media_gallery.py +++ b/disnake/ui/media_gallery.py @@ -19,7 +19,7 @@ class MediaGallery(UIComponent): Parameters ---------- - *items: :class:`MediaGalleryItem` + *items: :class:`.MediaGalleryItem` The list of images in this gallery. """ @@ -36,7 +36,7 @@ def __init__(self, *items: Sequence[MediaGalleryItem]) -> None: @property def items(self) -> List[MediaGalleryItem]: - """List[:class:`MediaGalleryItem`]: The images in this gallery.""" + """List[:class:`.MediaGalleryItem`]: The images in this gallery.""" return self._underlying.items @items.setter diff --git a/disnake/ui/section.py b/disnake/ui/section.py index be15a96de9..7d93cf0a9c 100644 --- a/disnake/ui/section.py +++ b/disnake/ui/section.py @@ -24,9 +24,9 @@ class Section(UIComponent): Parameters ---------- - *components: :class:`~ui.TextDisplay` + *components: :class:`~.ui.TextDisplay` The list of text items in this section. - accessory: Union[:class:`~ui.Thumbnail`, :class:`~ui.Button`] + accessory: Union[:class:`~.ui.Thumbnail`, :class:`~.ui.Button`] The accessory component displayed next to the section text. """ @@ -50,14 +50,12 @@ def __init__( # TODO: consider moving runtime checks from constructor into property setters, also making these fields writable @property def components(self) -> Sequence[TextDisplay]: - """Sequence[:class:`~ui.TextDisplay`]: - A read-only copy of the text items in this section. - """ + """Sequence[:class:`~.ui.TextDisplay`]: A read-only copy of the text items in this section.""" return SequenceProxy(self._components) @property def accessory(self) -> Union[Thumbnail, Button]: - """Union[:class:`~ui.Thumbnail`, :class:`~ui.Button`]: The accessory component displayed next to the section text.""" + """Union[:class:`~.ui.Thumbnail`, :class:`~.ui.Button`]: The accessory component displayed next to the section text.""" return self._accessory def __repr__(self) -> str: diff --git a/disnake/ui/separator.py b/disnake/ui/separator.py index dad9b59898..02969e8835 100644 --- a/disnake/ui/separator.py +++ b/disnake/ui/separator.py @@ -22,9 +22,9 @@ class Separator(UIComponent): divider: :class:`bool` Whether the separator should be visible, instead of just being vertical padding/spacing. Defaults to ``True``. - spacing: :class:`SeparatorSpacingSize` + spacing: :class:`.SeparatorSpacingSize` The size of the separator. - Defaults to :attr:`~SeparatorSpacingSize.small`. + Defaults to :attr:`~.SeparatorSpacingSize.small`. """ __repr_attributes__: Tuple[str, ...] = ( @@ -57,7 +57,7 @@ def divider(self, value: bool) -> None: @property def spacing(self) -> SeparatorSpacingSize: - """:class:`SeparatorSpacingSize`: The size of the separator.""" + """:class:`.SeparatorSpacingSize`: The size of the separator.""" return self._underlying.spacing @spacing.setter diff --git a/disnake/ui/thumbnail.py b/disnake/ui/thumbnail.py index 17b50b0b13..5f37781ccc 100644 --- a/disnake/ui/thumbnail.py +++ b/disnake/ui/thumbnail.py @@ -15,7 +15,7 @@ class Thumbnail(UIComponent): """Represents a UI thumbnail. - This is only supported as the :attr:`~ui.Section.accessory` of a section component. + This is only supported as the :attr:`~.ui.Section.accessory` of a section component. .. versionadded:: 2.11 diff --git a/docs/api/ui.rst b/docs/api/ui.rst index ea1efaffc4..e53355c0b1 100644 --- a/docs/api/ui.rst +++ b/docs/api/ui.rst @@ -133,6 +133,69 @@ TextInput .. autoclass:: TextInput :members: +Section +~~~~~~~ + +.. attributetable:: Section + +.. autoclass:: Section + :members: + :inherited-members: + +TextDisplay +~~~~~~~~~~~ + +.. attributetable:: TextDisplay + +.. autoclass:: TextDisplay + :members: + :inherited-members: + +Thumbnail +~~~~~~~~~ + +.. attributetable:: Thumbnail + +.. autoclass:: Thumbnail + :members: + :inherited-members: + +MediaGallery +~~~~~~~~~~~~ + +.. attributetable:: MediaGallery + +.. autoclass:: MediaGallery + :members: + :inherited-members: + +File +~~~~ + +.. attributetable:: File + +.. autoclass:: File + :members: + :inherited-members: + +Separator +~~~~~~~~~ + +.. attributetable:: Separator + +.. autoclass:: Separator + :members: + :inherited-members: + +Container +~~~~~~~~~ + +.. attributetable:: Container + +.. autoclass:: Container + :members: + :inherited-members: + Functions --------- From a95e1b211c1e57ec2f211857c43a8f793e137102 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 15 Mar 2025 15:20:37 +0100 Subject: [PATCH 028/104] fix(ui): fix `MediaGallery` parameter type --- disnake/ui/media_gallery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/disnake/ui/media_gallery.py b/disnake/ui/media_gallery.py index eb0f709a83..9653c63115 100644 --- a/disnake/ui/media_gallery.py +++ b/disnake/ui/media_gallery.py @@ -28,7 +28,7 @@ class MediaGallery(UIComponent): _underlying: MediaGalleryComponent = MISSING # FIXME: MediaGalleryItem currently isn't user-instantiable - def __init__(self, *items: Sequence[MediaGalleryItem]) -> None: + def __init__(self, *items: MediaGalleryItem) -> None: self._underlying = MediaGalleryComponent._raw_construct( type=ComponentType.media_gallery, items=list(items), From 6198aa625ff8e0b1ce47619504cd067b61490225 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 15 Mar 2025 17:05:25 +0100 Subject: [PATCH 029/104] refactor(types): move action row types to `ui._types`, add some shortcut types --- disnake/abc.py | 12 ++-- disnake/channel.py | 12 ++-- disnake/interactions/base.py | 24 +++---- disnake/message.py | 24 +++---- disnake/ui/_types.py | 57 ++++++++++++++++ disnake/ui/action_row.py | 128 ++++++++++++++++++----------------- disnake/ui/container.py | 4 +- disnake/ui/modal.py | 4 +- disnake/webhook/async_.py | 18 ++--- tests/ui/test_action_row.py | 21 +++--- 10 files changed, 183 insertions(+), 121 deletions(-) create mode 100644 disnake/ui/_types.py diff --git a/disnake/abc.py b/disnake/abc.py index a77b07d40b..7cca459c88 100644 --- a/disnake/abc.py +++ b/disnake/abc.py @@ -84,7 +84,7 @@ PermissionOverwrite as PermissionOverwritePayload, ) from .types.threads import PartialForumTag as PartialForumTagPayload - from .ui.action_row import Components, MessageUIComponent + from .ui._types import MessageComponentInput from .ui.view import View from .user import ClientUser from .voice_region import VoiceRegion @@ -1433,7 +1433,7 @@ async def send( reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., - components: Components[MessageUIComponent] = ..., + components: MessageComponentInput = ..., poll: Poll = ..., ) -> Message: ... @@ -1454,7 +1454,7 @@ async def send( reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., - components: Components[MessageUIComponent] = ..., + components: MessageComponentInput = ..., poll: Poll = ..., ) -> Message: ... @@ -1475,7 +1475,7 @@ async def send( reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., - components: Components[MessageUIComponent] = ..., + components: MessageComponentInput = ..., poll: Poll = ..., ) -> Message: ... @@ -1496,7 +1496,7 @@ async def send( reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., - components: Components[MessageUIComponent] = ..., + components: MessageComponentInput = ..., poll: Poll = ..., ) -> Message: ... @@ -1518,7 +1518,7 @@ async def send( reference: Optional[Union[Message, MessageReference, PartialMessage]] = None, mention_author: Optional[bool] = None, view: Optional[View] = None, - components: Optional[Components[MessageUIComponent]] = None, + components: Optional[MessageComponentInput] = None, poll: Optional[Poll] = None, ): """|coro| diff --git a/disnake/channel.py b/disnake/channel.py index 695e832bd5..9509c85e6f 100644 --- a/disnake/channel.py +++ b/disnake/channel.py @@ -95,7 +95,7 @@ from .types.soundboard import PartialSoundboardSound as PartialSoundboardSoundPayload from .types.threads import ThreadArchiveDurationLiteral from .types.voice import VoiceChannelEffect as VoiceChannelEffectPayload - from .ui.action_row import Components, MessageUIComponent + from .ui._types import MessageComponentInput from .ui.view import View from .user import BaseUser, ClientUser, User from .voice_region import VoiceRegion @@ -3484,7 +3484,7 @@ async def create_thread( stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = ..., allowed_mentions: AllowedMentions = ..., view: View = ..., - components: Components = ..., + components: MessageComponentInput = ..., reason: Optional[str] = None, ) -> ThreadWithMessage: ... @@ -3504,7 +3504,7 @@ async def create_thread( stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = ..., allowed_mentions: AllowedMentions = ..., view: View = ..., - components: Components = ..., + components: MessageComponentInput = ..., reason: Optional[str] = None, ) -> ThreadWithMessage: ... @@ -3524,7 +3524,7 @@ async def create_thread( stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = ..., allowed_mentions: AllowedMentions = ..., view: View = ..., - components: Components = ..., + components: MessageComponentInput = ..., reason: Optional[str] = None, ) -> ThreadWithMessage: ... @@ -3544,7 +3544,7 @@ async def create_thread( stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = ..., allowed_mentions: AllowedMentions = ..., view: View = ..., - components: Components = ..., + components: MessageComponentInput = ..., reason: Optional[str] = None, ) -> ThreadWithMessage: ... @@ -3565,7 +3565,7 @@ async def create_thread( stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = MISSING, allowed_mentions: AllowedMentions = MISSING, view: View = MISSING, - components: Components[MessageUIComponent] = MISSING, + components: MessageComponentInput = MISSING, reason: Optional[str] = None, ) -> ThreadWithMessage: """|coro| diff --git a/disnake/interactions/base.py b/disnake/interactions/base.py index ed92819045..fccc69d193 100644 --- a/disnake/interactions/base.py +++ b/disnake/interactions/base.py @@ -81,7 +81,7 @@ InteractionDataResolved as InteractionDataResolvedPayload, ) from ..types.snowflake import Snowflake - from ..ui.action_row import Components, MessageUIComponent, ModalUIComponent + from ..ui._types import MessageComponentInput, ModalComponentInput from ..ui.modal import Modal from ..ui.view import View from .message import MessageInteraction @@ -433,7 +433,7 @@ async def edit_original_response( files: List[File] = MISSING, attachments: Optional[List[Attachment]] = MISSING, view: Optional[View] = MISSING, - components: Optional[Components[MessageUIComponent]] = MISSING, + components: Optional[MessageComponentInput] = MISSING, poll: Poll = MISSING, suppress_embeds: bool = MISSING, flags: MessageFlags = MISSING, @@ -675,7 +675,7 @@ async def send( files: List[File] = MISSING, allowed_mentions: AllowedMentions = MISSING, view: View = MISSING, - components: Components[MessageUIComponent] = MISSING, + components: MessageComponentInput = MISSING, tts: bool = False, ephemeral: bool = MISSING, suppress_embeds: bool = MISSING, @@ -959,7 +959,7 @@ async def send_message( files: List[File] = MISSING, allowed_mentions: AllowedMentions = MISSING, view: View = MISSING, - components: Components[MessageUIComponent] = MISSING, + components: MessageComponentInput = MISSING, tts: bool = False, ephemeral: bool = MISSING, suppress_embeds: bool = MISSING, @@ -1153,7 +1153,7 @@ async def edit_message( attachments: Optional[List[Attachment]] = MISSING, allowed_mentions: AllowedMentions = MISSING, view: Optional[View] = MISSING, - components: Optional[Components[MessageUIComponent]] = MISSING, + components: Optional[MessageComponentInput] = MISSING, delete_after: Optional[float] = None, ) -> None: """|coro| @@ -1393,7 +1393,7 @@ async def send_modal( *, title: str, custom_id: str, - components: Components[ModalUIComponent], + components: ModalComponentInput, ) -> None: ... async def send_modal( @@ -1402,7 +1402,7 @@ async def send_modal( *, title: Optional[str] = None, custom_id: Optional[str] = None, - components: Optional[Components[ModalUIComponent]] = None, + components: Optional[ModalComponentInput] = None, ) -> None: """|coro| @@ -1644,7 +1644,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[Components[MessageUIComponent]] = ..., + components: Optional[MessageComponentInput] = ..., delete_after: Optional[float] = ..., ) -> InteractionMessage: ... @@ -1660,7 +1660,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[Components[MessageUIComponent]] = ..., + components: Optional[MessageComponentInput] = ..., delete_after: Optional[float] = ..., ) -> InteractionMessage: ... @@ -1676,7 +1676,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[Components[MessageUIComponent]] = ..., + components: Optional[MessageComponentInput] = ..., delete_after: Optional[float] = ..., ) -> InteractionMessage: ... @@ -1692,7 +1692,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[Components[MessageUIComponent]] = ..., + components: Optional[MessageComponentInput] = ..., delete_after: Optional[float] = ..., ) -> InteractionMessage: ... @@ -1709,7 +1709,7 @@ async def edit( flags: MessageFlags = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, view: Optional[View] = MISSING, - components: Optional[Components[MessageUIComponent]] = MISSING, + components: Optional[MessageComponentInput] = MISSING, delete_after: Optional[float] = None, ) -> Message: """|coro| diff --git a/disnake/message.py b/disnake/message.py index 245a5eec43..108ddc200d 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -90,7 +90,7 @@ ) from .types.threads import ThreadArchiveDurationLiteral from .types.user import User as UserPayload - from .ui.action_row import Components, MessageUIComponent + from .ui._types import MessageComponentInput from .ui.view import View EmojiInputType = Union[Emoji, PartialEmoji, str] @@ -152,7 +152,7 @@ async def _edit_handler( flags: MessageFlags, allowed_mentions: Optional[AllowedMentions], view: Optional[View], - components: Optional[Components[MessageUIComponent]], + components: Optional[MessageComponentInput], ) -> Message: if embed is not MISSING and embeds is not MISSING: raise TypeError("Cannot mix embed and embeds keyword arguments.") @@ -1868,7 +1868,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[Components[MessageUIComponent]] = ..., + components: Optional[MessageComponentInput] = ..., delete_after: Optional[float] = ..., ) -> Message: ... @@ -1884,7 +1884,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[Components[MessageUIComponent]] = ..., + components: Optional[MessageComponentInput] = ..., delete_after: Optional[float] = ..., ) -> Message: ... @@ -1900,7 +1900,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[Components[MessageUIComponent]] = ..., + components: Optional[MessageComponentInput] = ..., delete_after: Optional[float] = ..., ) -> Message: ... @@ -1916,7 +1916,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[Components[MessageUIComponent]] = ..., + components: Optional[MessageComponentInput] = ..., delete_after: Optional[float] = ..., ) -> Message: ... @@ -1934,7 +1934,7 @@ async def edit( flags: MessageFlags = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, view: Optional[View] = MISSING, - components: Optional[Components[MessageUIComponent]] = MISSING, + components: Optional[MessageComponentInput] = MISSING, delete_after: Optional[float] = None, ) -> Message: """|coro| @@ -2631,7 +2631,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[Components[MessageUIComponent]] = ..., + components: Optional[MessageComponentInput] = ..., delete_after: Optional[float] = ..., ) -> Message: ... @@ -2647,7 +2647,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[Components[MessageUIComponent]] = ..., + components: Optional[MessageComponentInput] = ..., delete_after: Optional[float] = ..., ) -> Message: ... @@ -2663,7 +2663,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[Components[MessageUIComponent]] = ..., + components: Optional[MessageComponentInput] = ..., delete_after: Optional[float] = ..., ) -> Message: ... @@ -2679,7 +2679,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[Components[MessageUIComponent]] = ..., + components: Optional[MessageComponentInput] = ..., delete_after: Optional[float] = ..., ) -> Message: ... @@ -2697,7 +2697,7 @@ async def edit( flags: MessageFlags = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, view: Optional[View] = MISSING, - components: Optional[Components[MessageUIComponent]] = MISSING, + components: Optional[MessageComponentInput] = MISSING, delete_after: Optional[float] = None, ) -> Message: """|coro| diff --git a/disnake/ui/_types.py b/disnake/ui/_types.py new file mode 100644 index 0000000000..c0f8793f15 --- /dev/null +++ b/disnake/ui/_types.py @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional, Sequence, TypeVar, Union + +if TYPE_CHECKING: + from typing_extensions import TypeAlias + + from . import ( + ActionRow, + Button, + Container, + File, + MediaGallery, + Section, + Separator, + TextDisplay, + TextInput, + ) + from .item import WrappedComponent + from .select import ChannelSelect, MentionableSelect, RoleSelect, StringSelect, UserSelect + from .view import View + +# TODO: consider if there are any useful types to make public (e.g. disnake-compass used MessageUIComponent) + +V_co = TypeVar("V_co", bound="Optional[View]", covariant=True) + +AnySelect = Union[ + "ChannelSelect[V_co]", + "MentionableSelect[V_co]", + "RoleSelect[V_co]", + "StringSelect[V_co]", + "UserSelect[V_co]", +] + +# valid `ActionRow.components` item types in a message/modal +ActionRowMessageComponent = Union["Button[Any]", "AnySelect[Any]"] +ActionRowModalComponent: TypeAlias = "TextInput" + +# valid message component types (v1/v2) +MessageTopLevelComponentV1: TypeAlias = "ActionRow[ActionRowMessageComponent]" + +ActionRowChildT = TypeVar("ActionRowChildT", bound="WrappedComponent") +# valid input types where action rows are expected +# (provides some shortcuts, such as converting lists to action rows) +ActionRowInput = Union[ + ActionRowChildT, # single child component + "ActionRow[ActionRowChildT]", # single action row + Sequence[ # multiple items, rows, or lists of items + Union[ActionRowChildT, "ActionRow[ActionRowChildT]", Sequence[ActionRowChildT]] + ], +] + +# shortcuts for valid actionrow-ish input types +MessageComponentInput = ActionRowInput[ActionRowMessageComponent] +ModalComponentInput = ActionRowInput[ActionRowModalComponent] diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index 303f163519..2c9a369817 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -4,7 +4,6 @@ from typing import ( TYPE_CHECKING, - Any, ClassVar, Generator, Generic, @@ -22,7 +21,7 @@ from ..components import ( ActionRow as ActionRowComponent, ActionRowChildComponent, - ActionRowMessageComponent, + ActionRowMessageComponent as ActionRowMessageComponentRaw, Button as ButtonComponent, ChannelSelectMenu as ChannelSelectComponent, MentionableSelectMenu as MentionableSelectComponent, @@ -32,13 +31,19 @@ ) from ..enums import ButtonStyle, ChannelType, ComponentType, TextInputStyle from ..utils import MISSING, SequenceProxy, assert_never +from ._types import ( + ActionRowChildT, + ActionRowInput, + ActionRowMessageComponent, + ActionRowModalComponent, +) from .button import Button from .item import WrappedComponent from .select import ChannelSelect, MentionableSelect, RoleSelect, StringSelect, UserSelect from .text_input import TextInput if TYPE_CHECKING: - from typing_extensions import Self + from typing_extensions import Self, TypeAlias from ..abc import AnyChannel from ..emoji import Emoji @@ -49,7 +54,7 @@ from ..types.components import ActionRow as ActionRowPayload from ..user import User from .select.base import SelectDefaultValueInputType, SelectDefaultValueMultiInputType - from .select.string import SelectOptionInput, V_co + from .select.string import SelectOptionInput __all__ = ( "ActionRow", @@ -60,43 +65,33 @@ "ModalActionRow", ) -AnySelect = Union[ - "ChannelSelect[V_co]", - "MentionableSelect[V_co]", - "RoleSelect[V_co]", - "StringSelect[V_co]", - "UserSelect[V_co]", -] - -MessageUIComponent = Union[Button[Any], "AnySelect[Any]"] -ModalUIComponent = TextInput # Union[TextInput, "AnySelect[Any]"] -UIComponentT = TypeVar("UIComponentT", bound=WrappedComponent) -StrictUIComponentT = TypeVar("StrictUIComponentT", MessageUIComponent, ModalUIComponent) - -Components = Union[ - "ActionRow[UIComponentT]", - UIComponentT, - Sequence[Union["ActionRow[UIComponentT]", UIComponentT, Sequence[UIComponentT]]], -] +# FIXME(3.0): legacy +MessageUIComponent: TypeAlias = ActionRowMessageComponent +ModalUIComponent: TypeAlias = ActionRowModalComponent +Components: TypeAlias = ActionRowInput[ActionRowChildT] + +StrictActionRowChildT = TypeVar( + "StrictActionRowChildT", ActionRowMessageComponent, ActionRowModalComponent +) # this is cursed ButtonCompatibleActionRowT = TypeVar( "ButtonCompatibleActionRowT", - bound="Union[ActionRow[MessageUIComponent], ActionRow[WrappedComponent]]", + bound="Union[ActionRow[ActionRowMessageComponent], ActionRow[WrappedComponent]]", ) SelectCompatibleActionRowT = TypeVar( "SelectCompatibleActionRowT", - bound="Union[ActionRow[MessageUIComponent], ActionRow[WrappedComponent]]", # to add: ActionRow[ModalUIComponent] + bound="Union[ActionRow[ActionRowMessageComponent], ActionRow[WrappedComponent]]", ) TextInputCompatibleActionRowT = TypeVar( "TextInputCompatibleActionRowT", - bound="Union[ActionRow[ModalUIComponent], ActionRow[WrappedComponent]]", + bound="Union[ActionRow[ActionRowModalComponent], ActionRow[WrappedComponent]]", ) def _message_component_to_item( - component: ActionRowMessageComponent, -) -> Optional[MessageUIComponent]: + component: ActionRowMessageComponentRaw, +) -> Optional[ActionRowMessageComponent]: if isinstance(component, ButtonComponent): return Button.from_component(component) if isinstance(component, StringSelectComponent): @@ -115,7 +110,7 @@ def _message_component_to_item( # TODO: this can likely also subclass the new `UIComponent` base type -class ActionRow(Generic[UIComponentT]): +class ActionRow(Generic[ActionRowChildT]): """Represents a UI action row. Useful for lower level component manipulation. .. collapse:: operations @@ -172,19 +167,27 @@ def __init__(self: ActionRow[WrappedComponent]) -> None: ... # differentiate themselves properly. @overload - def __init__(self: ActionRow[MessageUIComponent], *components: MessageUIComponent) -> None: ... + def __init__( + self: ActionRow[ActionRowMessageComponent], *components: ActionRowMessageComponent + ) -> None: ... @overload - def __init__(self: ActionRow[ModalUIComponent], *components: ModalUIComponent) -> None: ... + def __init__( + self: ActionRow[ActionRowModalComponent], *components: ActionRowModalComponent + ) -> None: ... # Allow use of "ActionRow[StrictUIComponent]" externally. @overload - def __init__(self: ActionRow[StrictUIComponentT], *components: StrictUIComponentT) -> None: ... + def __init__( + self: ActionRow[StrictActionRowChildT], *components: StrictActionRowChildT + ) -> 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] = [] + # n.b. this should be `*components: ActionRowChildT`, but pyright does not like it + def __init__( + self, *components: Union[ActionRowMessageComponent, ActionRowModalComponent] + ) -> None: + self._children: List[ActionRowChildT] = [] for component in components: if not isinstance(component, WrappedComponent): @@ -201,7 +204,7 @@ def __len__(self) -> int: return len(self._children) @property - def children(self) -> Sequence[UIComponentT]: + def children(self) -> Sequence[ActionRowChildT]: """Sequence[:class:`WrappedComponent`]: A read-only copy of the UI components stored in this action row. To add/remove components to/from the action row, use its methods to directly modify it. @@ -215,7 +218,7 @@ def children(self) -> Sequence[UIComponentT]: def width(self) -> int: return sum(child.width for child in self._children) - def append_item(self, item: UIComponentT) -> Self: + def append_item(self, item: ActionRowChildT) -> Self: """Append a component to the action row. The component's type must match that of the action row. @@ -234,7 +237,7 @@ def append_item(self, item: UIComponentT) -> Self: self.insert_item(len(self), item) return self - def insert_item(self, index: int, item: UIComponentT) -> Self: + def insert_item(self, index: int, item: ActionRowChildT) -> Self: """Insert a component to the action row at a given index. The component's type must match that of the action row. @@ -699,7 +702,7 @@ def clear_items(self) -> Self: self._children.clear() return self - def remove_item(self, item: UIComponentT) -> Self: + def remove_item(self, item: ActionRowChildT) -> Self: """Remove a component from the action row. This function returns the class instance to allow for fluent-style chaining. @@ -719,7 +722,7 @@ def remove_item(self, item: UIComponentT) -> Self: self._children.remove(item) return self - def pop(self, index: int) -> UIComponentT: + def pop(self, index: int) -> ActionRowChildT: """Pop the component at the provided index from the action row. .. versionadded:: 2.6 @@ -751,19 +754,21 @@ def __delitem__(self, index: Union[int, slice]) -> None: del self._children[index] @overload - def __getitem__(self, index: int) -> UIComponentT: ... + def __getitem__(self, index: int) -> ActionRowChildT: ... @overload - def __getitem__(self, index: slice) -> Sequence[UIComponentT]: ... + def __getitem__(self, index: slice) -> Sequence[ActionRowChildT]: ... - def __getitem__(self, index: Union[int, slice]) -> Union[UIComponentT, Sequence[UIComponentT]]: + def __getitem__( + self, index: Union[int, slice] + ) -> Union[ActionRowChildT, Sequence[ActionRowChildT]]: return self._children[index] - def __iter__(self) -> Iterator[UIComponentT]: + def __iter__(self) -> Iterator[ActionRowChildT]: return iter(self._children) @classmethod - def with_modal_components(cls) -> ActionRow[ModalUIComponent]: + def with_modal_components(cls) -> ActionRow[ActionRowModalComponent]: """Create an empty action row meant to store components compatible with :class:`disnake.ui.Modal`. Saves the need to import type specifiers to typehint empty action rows. @@ -775,10 +780,10 @@ def with_modal_components(cls) -> ActionRow[ModalUIComponent]: :class:`ActionRow`: The newly created empty action row, intended for modal components. """ - return ActionRow[ModalUIComponent]() + return ActionRow[ActionRowModalComponent]() @classmethod - def with_message_components(cls) -> ActionRow[MessageUIComponent]: + def with_message_components(cls) -> ActionRow[ActionRowMessageComponent]: """Create an empty action row meant to store components compatible with :class:`disnake.Message`. Saves the need to import type specifiers to typehint empty action rows. @@ -790,7 +795,7 @@ def with_message_components(cls) -> ActionRow[MessageUIComponent]: :class:`ActionRow`: The newly created empty action row, intended for message components. """ - return ActionRow[MessageUIComponent]() + return ActionRow[ActionRowMessageComponent]() @classmethod def rows_from_message( @@ -798,7 +803,7 @@ def rows_from_message( message: Message, *, strict: bool = True, - ) -> List[ActionRow[MessageUIComponent]]: + ) -> List[ActionRow[ActionRowMessageComponent]]: """Create a list of up to 5 action rows from the components on an existing message. This will abide by existing component format on the message, including component @@ -825,7 +830,7 @@ def rows_from_message( List[:class:`ActionRow`]: The action rows parsed from the components on the message. """ - rows: List[ActionRow[MessageUIComponent]] = [] + rows: List[ActionRow[ActionRowMessageComponent]] = [] for row in message.components: if not isinstance(row, ActionRowComponent): # can happen if message uses components v2 @@ -844,8 +849,8 @@ def rows_from_message( @staticmethod def walk_components( - action_rows: Sequence[ActionRow[UIComponentT]], - ) -> Generator[Tuple[ActionRow[UIComponentT], UIComponentT], None, None]: + action_rows: Sequence[ActionRow[ActionRowChildT]], + ) -> Generator[Tuple[ActionRow[ActionRowChildT], ActionRowChildT], None, None]: """Iterate over the components in a sequence of action rows, yielding each individual component together with the action row of which it is a child. @@ -866,18 +871,19 @@ def walk_components( yield row, component -MessageActionRow = ActionRow[MessageUIComponent] -ModalActionRow = ActionRow[ModalUIComponent] +# FIXME(3.0): consider removing +MessageActionRow = ActionRow[ActionRowMessageComponent] +ModalActionRow = ActionRow[ActionRowModalComponent] def components_to_rows( - components: Components[StrictUIComponentT], -) -> List[ActionRow[StrictUIComponentT]]: + components: ActionRowInput[StrictActionRowChildT], +) -> List[ActionRow[StrictActionRowChildT]]: if not isinstance(components, Sequence): components = [components] - action_rows: List[ActionRow[StrictUIComponentT]] = [] - auto_row: ActionRow[StrictUIComponentT] = ActionRow[StrictUIComponentT]() + action_rows: List[ActionRow[StrictActionRowChildT]] = [] + auto_row: ActionRow[StrictActionRowChildT] = ActionRow[StrictActionRowChildT]() for component in components: if isinstance(component, WrappedComponent): @@ -885,17 +891,17 @@ def components_to_rows( auto_row.append_item(component) except ValueError: action_rows.append(auto_row) - auto_row = ActionRow[StrictUIComponentT](component) + auto_row = ActionRow[StrictActionRowChildT](component) else: if auto_row.width > 0: action_rows.append(auto_row) - auto_row = ActionRow[StrictUIComponentT]() + auto_row = ActionRow[StrictActionRowChildT]() if isinstance(component, ActionRow): action_rows.append(component) elif isinstance(component, Sequence): - action_rows.append(ActionRow[StrictUIComponentT](*component)) + action_rows.append(ActionRow[StrictActionRowChildT](*component)) else: raise TypeError( @@ -910,5 +916,5 @@ def components_to_rows( return action_rows -def components_to_dict(components: Components[StrictUIComponentT]) -> List[ActionRowPayload]: +def components_to_dict(components: ActionRowInput[StrictActionRowChildT]) -> List[ActionRowPayload]: return [row.to_component_dict() for row in components_to_rows(components)] diff --git a/disnake/ui/container.py b/disnake/ui/container.py index 5a319d1457..e155485e55 100644 --- a/disnake/ui/container.py +++ b/disnake/ui/container.py @@ -11,7 +11,7 @@ from .item import UIComponent, ensure_ui_component if TYPE_CHECKING: - from .action_row import ActionRow, MessageUIComponent + from .action_row import ActionRow, ActionRowMessageComponent from .file import File from .media_gallery import MediaGallery from .section import Section @@ -19,7 +19,7 @@ from .text_display import TextDisplay ContainerChildUIComponent = Union[ - ActionRow[MessageUIComponent], + ActionRow[ActionRowMessageComponent], Section, TextDisplay, MediaGallery, diff --git a/disnake/ui/modal.py b/disnake/ui/modal.py index 7f0192c3b8..cd73da22a5 100644 --- a/disnake/ui/modal.py +++ b/disnake/ui/modal.py @@ -19,7 +19,7 @@ from ..interactions.modal import ModalInteraction from ..state import ConnectionState from ..types.components import Modal as ModalPayload - from .action_row import Components, ModalUIComponent + from ..ui._types import ModalComponentInput __all__ = ("Modal",) @@ -70,7 +70,7 @@ def __init__( self, *, title: str, - components: Components[ModalUIComponent], + components: ModalComponentInput, custom_id: str = MISSING, timeout: float = 600, ) -> None: diff --git a/disnake/webhook/async_.py b/disnake/webhook/async_.py index c4b77e2c43..3e9a3468e5 100644 --- a/disnake/webhook/async_.py +++ b/disnake/webhook/async_.py @@ -38,7 +38,7 @@ from ..message import Message from ..mixins import Hashable from ..object import Object -from ..ui.action_row import MessageUIComponent, components_to_dict +from ..ui.action_row import components_to_dict from ..user import BaseUser, User __all__ = ( @@ -68,7 +68,7 @@ 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 + from ..ui._types import MessageComponentInput from ..ui.view import View MISSING = utils.MISSING @@ -508,7 +508,7 @@ def handle_message_parameters_dict( embed: Optional[Embed] = MISSING, embeds: List[Embed] = MISSING, view: Optional[View] = MISSING, - components: Optional[Components[MessageUIComponent]] = MISSING, + components: Optional[MessageComponentInput] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, previous_allowed_mentions: Optional[AllowedMentions] = None, stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = MISSING, @@ -602,7 +602,7 @@ def handle_message_parameters( embed: Optional[Embed] = MISSING, embeds: List[Embed] = MISSING, view: Optional[View] = MISSING, - components: Optional[Components[MessageUIComponent]] = MISSING, + components: Optional[MessageComponentInput] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, previous_allowed_mentions: Optional[AllowedMentions] = None, stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = MISSING, @@ -789,7 +789,7 @@ async def edit( files: List[File] = MISSING, attachments: Optional[List[Attachment]] = MISSING, view: Optional[View] = MISSING, - components: Optional[Components[MessageUIComponent]] = MISSING, + components: Optional[MessageComponentInput] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, ) -> WebhookMessage: """|coro| @@ -1500,7 +1500,7 @@ async def send( embeds: List[Embed] = ..., allowed_mentions: AllowedMentions = ..., view: View = ..., - components: Components[MessageUIComponent] = ..., + components: MessageComponentInput = ..., poll: Poll = ..., thread: Snowflake = ..., thread_name: str = ..., @@ -1526,7 +1526,7 @@ async def send( embeds: List[Embed] = ..., allowed_mentions: AllowedMentions = ..., view: View = ..., - components: Components[MessageUIComponent] = ..., + components: MessageComponentInput = ..., poll: Poll = ..., thread: Snowflake = ..., thread_name: str = ..., @@ -1551,7 +1551,7 @@ async def send( embeds: List[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, view: View = MISSING, - components: Components[MessageUIComponent] = MISSING, + components: MessageComponentInput = MISSING, thread: Snowflake = MISSING, thread_name: str = MISSING, applied_tags: Sequence[Snowflake] = MISSING, @@ -1853,7 +1853,7 @@ async def edit_message( files: List[File] = MISSING, attachments: Optional[List[Attachment]] = MISSING, view: Optional[View] = MISSING, - components: Optional[Components[MessageUIComponent]] = MISSING, + components: Optional[MessageComponentInput] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, thread: Optional[Snowflake] = None, ) -> WebhookMessage: diff --git a/tests/ui/test_action_row.py b/tests/ui/test_action_row.py index f9c40ffedc..12bc7faea3 100644 --- a/tests/ui/test_action_row.py +++ b/tests/ui/test_action_row.py @@ -10,12 +10,11 @@ from disnake.ui import ( ActionRow, Button, - MessageUIComponent, - ModalUIComponent, StringSelect, TextInput, WrappedComponent, ) +from disnake.ui._types import ActionRowMessageComponent, ActionRowModalComponent from disnake.ui.action_row import components_to_dict, components_to_rows button1 = Button() @@ -136,8 +135,8 @@ def test_with_components(self) -> None: row_msg = ActionRow.with_message_components() assert list(row_msg.children) == [] - assert_type(row_modal, ActionRow[ModalUIComponent]) - assert_type(row_msg, ActionRow[MessageUIComponent]) + assert_type(row_modal, ActionRow[ActionRowModalComponent]) + assert_type(row_msg, ActionRow[ActionRowMessageComponent]) def test_rows_from_message(self) -> None: rows = [ @@ -200,12 +199,12 @@ def test_walk_components(self) -> None: def _test_typing_init(self) -> None: # pragma: no cover assert_type(ActionRow(), ActionRow[WrappedComponent]) - assert_type(ActionRow(button1), ActionRow[MessageUIComponent]) - assert_type(ActionRow(select), ActionRow[MessageUIComponent]) - assert_type(ActionRow(text_input), ActionRow[ModalUIComponent]) + assert_type(ActionRow(button1), ActionRow[ActionRowMessageComponent]) + assert_type(ActionRow(select), ActionRow[ActionRowMessageComponent]) + assert_type(ActionRow(text_input), ActionRow[ActionRowModalComponent]) - assert_type(ActionRow(button1, select), ActionRow[MessageUIComponent]) - assert_type(ActionRow(select, button1), ActionRow[MessageUIComponent]) + assert_type(ActionRow(button1, select), ActionRow[ActionRowMessageComponent]) + assert_type(ActionRow(select, button1), ActionRow[ActionRowMessageComponent]) # these should fail to type-check - if they pass, there will be an error # because of the unnecessary ignore comment @@ -213,8 +212,8 @@ def _test_typing_init(self) -> None: # pragma: no cover ActionRow(text_input, button1) # type: ignore # TODO: revert when modal select support is added. - assert_type(ActionRow(select, text_input), ActionRow[ModalUIComponent]) # type: ignore - assert_type(ActionRow(text_input, select), ActionRow[ModalUIComponent]) # type: ignore + assert_type(ActionRow(select, text_input), ActionRow[ActionRowModalComponent]) # type: ignore + assert_type(ActionRow(text_input, select), ActionRow[ActionRowModalComponent]) # type: ignore @pytest.mark.parametrize( From 0ded39db4c5e73329060ac7ee0a529336899d655 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 15 Mar 2025 17:06:47 +0100 Subject: [PATCH 030/104] feat(types): add new components to `MessageComponentInput` union --- disnake/components.py | 1 - disnake/ui/_types.py | 15 ++++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index b45f1c20a1..b59a5e41b7 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -133,7 +133,6 @@ # valid `Message.components` item types (v1/v2) MessageTopLevelComponentV1: TypeAlias = "ActionRow[ActionRowMessageComponent]" MessageTopLevelComponentV2 = Union[ - MessageTopLevelComponentV1, "Section", "TextDisplay", "MediaGallery", diff --git a/disnake/ui/_types.py b/disnake/ui/_types.py index c0f8793f15..3926d7927e 100644 --- a/disnake/ui/_types.py +++ b/disnake/ui/_types.py @@ -40,6 +40,14 @@ # valid message component types (v1/v2) MessageTopLevelComponentV1: TypeAlias = "ActionRow[ActionRowMessageComponent]" +MessageTopLevelComponentV2 = Union[ + "Section", + "TextDisplay", + "MediaGallery", + "File", + "Separator", + "Container", +] ActionRowChildT = TypeVar("ActionRowChildT", bound="WrappedComponent") # valid input types where action rows are expected @@ -53,5 +61,10 @@ ] # shortcuts for valid actionrow-ish input types -MessageComponentInput = ActionRowInput[ActionRowMessageComponent] +MessageComponentInputV1 = ActionRowInput[ActionRowMessageComponent] +MessageComponentInputV2 = Union[ + MessageTopLevelComponentV2, + Sequence[MessageTopLevelComponentV2], +] +MessageComponentInput = Union[MessageComponentInputV1, MessageComponentInputV2] ModalComponentInput = ActionRowInput[ActionRowModalComponent] From 2ad9e10c80d10dcc79d7d4a95826c94ab3292be3 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 18 Apr 2025 18:45:48 +0200 Subject: [PATCH 031/104] feat: support v2 components in `normalize_components`/`components_to_rows` --- disnake/ui/_types.py | 39 +++++++++++-------- disnake/ui/action_row.py | 75 ++++++++++++++++++++++--------------- disnake/ui/modal.py | 6 +-- tests/ui/test_action_row.py | 24 +++++++----- 4 files changed, 85 insertions(+), 59 deletions(-) diff --git a/disnake/ui/_types.py b/disnake/ui/_types.py index 3926d7927e..0f71fbbde3 100644 --- a/disnake/ui/_types.py +++ b/disnake/ui/_types.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Optional, Sequence, TypeVar, Union +from typing import TYPE_CHECKING, Any, NoReturn, Optional, Sequence, TypeVar, Union if TYPE_CHECKING: from typing_extensions import TypeAlias @@ -48,23 +48,30 @@ "Separator", "Container", ] +MessageTopLevelComponent = Union[MessageTopLevelComponentV1, MessageTopLevelComponentV2] ActionRowChildT = TypeVar("ActionRowChildT", bound="WrappedComponent") -# valid input types where action rows are expected -# (provides some shortcuts, such as converting lists to action rows) -ActionRowInput = Union[ - ActionRowChildT, # single child component - "ActionRow[ActionRowChildT]", # single action row - Sequence[ # multiple items, rows, or lists of items - Union[ActionRowChildT, "ActionRow[ActionRowChildT]", Sequence[ActionRowChildT]] - ], +NonActionRowChildT = TypeVar("NonActionRowChildT", bound=MessageTopLevelComponentV2) + +# generic utility type for any single ui component (within some generic bounds) +AnyUIComponentInput = Union[ + ActionRowChildT, # action row child component + "ActionRow[ActionRowChildT]", # action row with given child types + NonActionRowChildT, # some subset of (v2) components that work outside of action rows ] -# shortcuts for valid actionrow-ish input types -MessageComponentInputV1 = ActionRowInput[ActionRowMessageComponent] -MessageComponentInputV2 = Union[ - MessageTopLevelComponentV2, - Sequence[MessageTopLevelComponentV2], +# The generic to end all generics. +# This represents valid input types where components are expected, +# providing some shortcuts/quality-of-life input shapes. +ComponentInput = Union[ + AnyUIComponentInput[ActionRowChildT, NonActionRowChildT], # any single component + Sequence[ # or, a sequence of either - + Union[ + AnyUIComponentInput[ActionRowChildT, NonActionRowChildT], # - any single component + Sequence[ActionRowChildT], # - a sequence of action row child types + ] + ], ] -MessageComponentInput = Union[MessageComponentInputV1, MessageComponentInputV2] -ModalComponentInput = ActionRowInput[ActionRowModalComponent] + +MessageComponentInput = ComponentInput[ActionRowMessageComponent, MessageTopLevelComponentV2] +ModalComponentInput = ComponentInput[ActionRowModalComponent, NoReturn] diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index 2c9a369817..1a79ebf587 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -10,6 +10,7 @@ Iterator, List, Literal, + NoReturn, Optional, Sequence, Tuple, @@ -33,12 +34,13 @@ from ..utils import MISSING, SequenceProxy, assert_never from ._types import ( ActionRowChildT, - ActionRowInput, ActionRowMessageComponent, ActionRowModalComponent, + ComponentInput, + NonActionRowChildT, ) from .button import Button -from .item import WrappedComponent +from .item import UIComponent, WrappedComponent from .select import ChannelSelect, MentionableSelect, RoleSelect, StringSelect, UserSelect from .text_input import TextInput @@ -68,7 +70,7 @@ # FIXME(3.0): legacy MessageUIComponent: TypeAlias = ActionRowMessageComponent ModalUIComponent: TypeAlias = ActionRowModalComponent -Components: TypeAlias = ActionRowInput[ActionRowChildT] +Components: TypeAlias = ComponentInput[ActionRowChildT, NoReturn] StrictActionRowChildT = TypeVar( "StrictActionRowChildT", ActionRowMessageComponent, ActionRowModalComponent @@ -176,17 +178,11 @@ def __init__( self: ActionRow[ActionRowModalComponent], *components: ActionRowModalComponent ) -> None: ... - # Allow use of "ActionRow[StrictUIComponent]" externally. - @overload - def __init__( - self: ActionRow[StrictActionRowChildT], *components: StrictActionRowChildT - ) -> None: ... + def __init__(self, *components: ActionRowChildT) -> None: ... # n.b. this should be `*components: ActionRowChildT`, but pyright does not like it - def __init__( - self, *components: Union[ActionRowMessageComponent, ActionRowModalComponent] - ) -> None: + def __init__(self, *components: WrappedComponent) -> None: self._children: List[ActionRowChildT] = [] for component in components: @@ -876,45 +872,64 @@ def walk_components( ModalActionRow = ActionRow[ActionRowModalComponent] -def components_to_rows( - components: ActionRowInput[StrictActionRowChildT], -) -> List[ActionRow[StrictActionRowChildT]]: +@overload +def normalize_components( + components: ComponentInput[NoReturn, NonActionRowChildT], / +) -> Sequence[NonActionRowChildT]: ... + + +@overload +def normalize_components( + components: ComponentInput[ActionRowChildT, NonActionRowChildT], / +) -> Sequence[Union[ActionRow[ActionRowChildT], NonActionRowChildT]]: ... + + +def normalize_components( + components: ComponentInput[ActionRowChildT, NonActionRowChildT], / +) -> Sequence[Union[ActionRow[ActionRowChildT], NonActionRowChildT]]: if not isinstance(components, Sequence): components = [components] - action_rows: List[ActionRow[StrictActionRowChildT]] = [] - auto_row: ActionRow[StrictActionRowChildT] = ActionRow[StrictActionRowChildT]() + result: List[Union[ActionRow[ActionRowChildT], NonActionRowChildT]] = [] + auto_row: ActionRow[ActionRowChildT] = ActionRow[ActionRowChildT]() for component in components: if isinstance(component, WrappedComponent): + # action row child component, try to insert into current row, otherwise create new row try: auto_row.append_item(component) except ValueError: - action_rows.append(auto_row) - auto_row = ActionRow[StrictActionRowChildT](component) + result.append(auto_row) + auto_row = ActionRow[ActionRowChildT](component) else: if auto_row.width > 0: - action_rows.append(auto_row) - auto_row = ActionRow[StrictActionRowChildT]() + # if the current action row has items, finish it + result.append(auto_row) + auto_row = ActionRow[ActionRowChildT]() - if isinstance(component, ActionRow): - action_rows.append(component) + # FIXME: once issubclass(ActionRow, UIComponent), simplify this + if isinstance(component, (ActionRow, UIComponent)): + # append non-actionrow-child components (action rows or v2 components) as-is + result.append(component) elif isinstance(component, Sequence): - action_rows.append(ActionRow[StrictActionRowChildT](*component)) + result.append(ActionRow[ActionRowChildT](*component)) else: + assert_never(component) raise TypeError( - "`components` must be a `WrappedComponent` or `ActionRow`, " - "a sequence/list of `WrappedComponent`s or `ActionRow`s, " - "or a nested sequence/list of `WrappedComponent`s" + "`components` must be a single component, " + "a sequence/list of components (or action rows), " + "or a nested sequence/list of action row compatible components" ) if auto_row.width > 0: - action_rows.append(auto_row) + result.append(auto_row) - return action_rows + return result -def components_to_dict(components: ActionRowInput[StrictActionRowChildT]) -> List[ActionRowPayload]: - return [row.to_component_dict() for row in components_to_rows(components)] +def components_to_dict( + components: ComponentInput[ActionRowChildT, NonActionRowChildT], +) -> List[ActionRowPayload]: + return [row.to_component_dict() for row in normalize_components(components)] diff --git a/disnake/ui/modal.py b/disnake/ui/modal.py index cd73da22a5..f90845aacc 100644 --- a/disnake/ui/modal.py +++ b/disnake/ui/modal.py @@ -11,7 +11,7 @@ from ..enums import TextInputStyle from ..utils import MISSING -from .action_row import ActionRow, components_to_rows +from .action_row import ActionRow, normalize_components from .text_input import TextInput if TYPE_CHECKING: @@ -77,13 +77,13 @@ def __init__( if timeout is None: # pyright: ignore[reportUnnecessaryComparison] raise ValueError("Timeout may not be None") - rows = components_to_rows(components) + rows = normalize_components(components) if len(rows) > 5: raise ValueError("Maximum number of components exceeded.") self.title: str = title self.custom_id: str = os.urandom(16).hex() if custom_id is MISSING else custom_id - self.components: List[ActionRow] = rows + self.components: List[ActionRow] = list(rows) self.timeout: float = timeout # function for the modal to remove itself from the store, if any diff --git a/tests/ui/test_action_row.py b/tests/ui/test_action_row.py index 12bc7faea3..6deb2e5320 100644 --- a/tests/ui/test_action_row.py +++ b/tests/ui/test_action_row.py @@ -15,7 +15,7 @@ WrappedComponent, ) from disnake.ui._types import ActionRowMessageComponent, ActionRowModalComponent -from disnake.ui.action_row import components_to_dict, components_to_rows +from disnake.ui.action_row import components_to_dict, normalize_components button1 = Button() button2 = Button() @@ -206,16 +206,20 @@ def _test_typing_init(self) -> None: # pragma: no cover assert_type(ActionRow(button1, select), ActionRow[ActionRowMessageComponent]) assert_type(ActionRow(select, button1), ActionRow[ActionRowMessageComponent]) - # these should fail to type-check - if they pass, there will be an error - # because of the unnecessary ignore comment - ActionRow(button1, text_input) # type: ignore - ActionRow(text_input, button1) # type: ignore + # TODO: no longer works since the overload changed for normalize_components. may revisit this. + # # these should fail to type-check - if they pass, there will be an error + # # because of the unnecessary ignore comment + # ActionRow(button1, text_input) + # ActionRow(text_input, button1) # TODO: revert when modal select support is added. assert_type(ActionRow(select, text_input), ActionRow[ActionRowModalComponent]) # type: ignore assert_type(ActionRow(text_input, select), ActionRow[ActionRowModalComponent]) # type: ignore +# TODO: expand tests to cover v2 components + + @pytest.mark.parametrize( ("value", "expected"), [ @@ -239,19 +243,19 @@ def _test_typing_init(self) -> None: # pragma: no cover ([select, button1, button2], [[select], [button1, button2]]), ], ) -def test_components_to_rows(value, expected) -> None: - rows = components_to_rows(value) +def test_normalize_components(value, expected) -> None: + rows = normalize_components(value) assert all(isinstance(row, ActionRow) for row in rows) assert [list(row.children) for row in rows] == expected -def test_components_to_rows__invalid() -> None: +def test_normalize_components__invalid() -> None: for value in (42, [42], [ActionRow(), 42], iter([button1])): with pytest.raises(TypeError, match=r"`components` must be a"): - components_to_rows(value) # type: ignore + normalize_components(value) # type: ignore for value in ([[[]]], [[[ActionRow()]]]): with pytest.raises(TypeError, match=r"components should be of type"): - components_to_rows(value) # type: ignore + normalize_components(value) # type: ignore def test_components_to_dict() -> None: From 58b60452316c724b5eda648cbab3707654811565 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 18 Apr 2025 19:07:58 +0200 Subject: [PATCH 032/104] docs: add changelog entry --- changelog/1294.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/1294.feature.rst diff --git a/changelog/1294.feature.rst b/changelog/1294.feature.rst new file mode 100644 index 0000000000..2a34ea6bdf --- /dev/null +++ b/changelog/1294.feature.rst @@ -0,0 +1 @@ +TODO. From 69591bf2da8eb10c77d7d5bd51661fc98166ccbb Mon Sep 17 00:00:00 2001 From: shiftinv Date: Mon, 9 Jun 2025 11:58:48 +0200 Subject: [PATCH 033/104] fix: make `ActionRow` subclass `UIComponent` --- disnake/interactions/base.py | 6 +++--- disnake/ui/action_row.py | 28 +++++++++++++++++----------- disnake/ui/container.py | 4 +--- disnake/ui/item.py | 1 + docs/conf.py | 2 +- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/disnake/interactions/base.py b/disnake/interactions/base.py index fccc69d193..c81676df9d 100644 --- a/disnake/interactions/base.py +++ b/disnake/interactions/base.py @@ -49,7 +49,7 @@ from ..object import Object from ..permissions import Permissions from ..role import Role -from ..ui.action_row import components_to_dict +from ..ui.action_row import components_to_dict, normalize_components from ..user import ClientUser, User from ..webhook.async_ import Webhook, async_context, handle_message_parameters @@ -1457,14 +1457,14 @@ async def send_modal( if modal is not None: modal_data = modal.to_components() elif title and components and custom_id: - rows = components_to_dict(components) + rows = normalize_components(components) if len(rows) > 5: raise ValueError("Maximum number of components exceeded.") modal_data = { "title": title, "custom_id": custom_id, - "components": rows, + "components": [component.to_component_dict() for component in rows], } else: raise TypeError("Either modal or title, custom_id, components must be provided") diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index 1a79ebf587..6f14a85bf2 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -4,18 +4,17 @@ from typing import ( TYPE_CHECKING, - ClassVar, Generator, Generic, Iterator, List, - Literal, NoReturn, Optional, Sequence, Tuple, TypeVar, Union, + cast, overload, ) @@ -53,7 +52,10 @@ from ..message import Message from ..partial_emoji import PartialEmoji from ..role import Role - from ..types.components import ActionRow as ActionRowPayload + from ..types.components import ( + ActionRow as ActionRowPayload, + MessageTopLevelComponent as MessageTopLevelComponentPayload, + ) from ..user import User from .select.base import SelectDefaultValueInputType, SelectDefaultValueMultiInputType from .select.string import SelectOptionInput @@ -111,8 +113,7 @@ def _message_component_to_item( return None -# TODO: this can likely also subclass the new `UIComponent` base type -class ActionRow(Generic[ActionRowChildT]): +class ActionRow(UIComponent, Generic[ActionRowChildT]): """Represents a UI action row. Useful for lower level component manipulation. .. collapse:: operations @@ -156,7 +157,8 @@ class ActionRow(Generic[ActionRowChildT]): context of a modal. Combining components from both contexts is not supported. """ - type: ClassVar[Literal[ComponentType.action_row]] = ComponentType.action_row + # unused, but technically required by base type + __repr_attributes__: Tuple[str, ...] = ("children",) # When unspecified and called empty, default to an ActionRow that takes any kind of component. @@ -193,6 +195,7 @@ def __init__(self, *components: WrappedComponent) -> None: self.append_item(component) # type: ignore def __repr__(self) -> str: + # implemented separately for now, due to SequenceProxy repr return f"" # FIXME(3.0)?: `bool(ActionRow())` returns False, which may be undesired @@ -739,10 +742,11 @@ def pop(self, index: int) -> ActionRowChildT: @property def _underlying(self) -> ActionRowComponent[ActionRowChildComponent]: return ActionRowComponent._raw_construct( - type=self.type, + type=ComponentType.action_row, children=[comp._underlying for comp in self._children], ) + # already provided by base type, reimplemented here for more precise return types def to_component_dict(self) -> ActionRowPayload: return self._underlying.to_dict() @@ -907,8 +911,7 @@ def normalize_components( result.append(auto_row) auto_row = ActionRow[ActionRowChildT]() - # FIXME: once issubclass(ActionRow, UIComponent), simplify this - if isinstance(component, (ActionRow, UIComponent)): + if isinstance(component, UIComponent): # append non-actionrow-child components (action rows or v2 components) as-is result.append(component) @@ -931,5 +934,8 @@ def normalize_components( def components_to_dict( components: ComponentInput[ActionRowChildT, NonActionRowChildT], -) -> List[ActionRowPayload]: - return [row.to_component_dict() for row in normalize_components(components)] +) -> List[MessageTopLevelComponentPayload]: + return [ + cast("MessageTopLevelComponentPayload", c.to_component_dict()) + for c in normalize_components(components) + ] diff --git a/disnake/ui/container.py b/disnake/ui/container.py index e155485e55..ed00630064 100644 --- a/disnake/ui/container.py +++ b/disnake/ui/container.py @@ -69,9 +69,7 @@ def __init__( spoiler: bool = False, ) -> None: self._components: List[ContainerChildUIComponent] = [ - # FIXME: typing broken until action rows become UIComponents - ensure_ui_component(c, "components") - for c in components + ensure_ui_component(c, "components") for c in components ] # FIXME: add accent_color self.accent_colour: Optional[Colour] = accent_colour diff --git a/disnake/ui/item.py b/disnake/ui/item.py index a926ce79c8..d3bcb68442 100644 --- a/disnake/ui/item.py +++ b/disnake/ui/item.py @@ -54,6 +54,7 @@ class UIComponent(ABC): The following classes implement this ABC: + - :class:`disnake.ui.ActionRow` - :class:`disnake.ui.Button` - subtypes of :class:`disnake.ui.BaseSelect` (:class:`disnake.ui.ChannelSelect`, :class:`disnake.ui.MentionableSelect`, :class:`disnake.ui.RoleSelect`, :class:`disnake.ui.StringSelect`, :class:`disnake.ui.UserSelect`) - :class:`disnake.ui.TextInput` diff --git a/docs/conf.py b/docs/conf.py index fe65c28491..65452b3b7f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -78,7 +78,7 @@ .. |coro| replace:: This function is a |coroutine_link|_. .. |maybecoro| replace:: This function *could be a* |coroutine_link|_. .. |coroutine_link| replace:: *coroutine* -.. |components_type| replace:: Union[:class:`disnake.ui.ActionRow`, :class:`disnake.ui.UIComponent`, List[Union[:class:`disnake.ui.ActionRow`, :class:`disnake.ui.UIComponent`, List[:class:`disnake.ui.WrappedComponent`]]]] +.. |components_type| replace:: Union[:class:`disnake.ui.UIComponent`, List[Union[:class:`disnake.ui.UIComponent`, List[:class:`disnake.ui.WrappedComponent`]]]] .. |resource_type| replace:: Union[:class:`bytes`, :class:`.Asset`, :class:`.Emoji`, :class:`.PartialEmoji`, :class:`.StickerItem`, :class:`.Sticker`] .. _coroutine_link: https://docs.python.org/3/library/asyncio-task.html#coroutine """ From a8fb92354fcbe91d0e7eeaf04251653a82185e93 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Mon, 9 Jun 2025 12:05:59 +0200 Subject: [PATCH 034/104] chore: rename `(Message|Modal)ComponentInput` utility type to `(Message|Modal)Components` --- disnake/abc.py | 12 ++++++------ disnake/channel.py | 12 ++++++------ disnake/interactions/base.py | 24 ++++++++++++------------ disnake/message.py | 24 ++++++++++++------------ disnake/ui/_types.py | 4 ++-- disnake/ui/modal.py | 4 ++-- disnake/webhook/async_.py | 16 ++++++++-------- 7 files changed, 48 insertions(+), 48 deletions(-) diff --git a/disnake/abc.py b/disnake/abc.py index 7cca459c88..9d1b096271 100644 --- a/disnake/abc.py +++ b/disnake/abc.py @@ -84,7 +84,7 @@ PermissionOverwrite as PermissionOverwritePayload, ) from .types.threads import PartialForumTag as PartialForumTagPayload - from .ui._types import MessageComponentInput + from .ui._types import MessageComponents from .ui.view import View from .user import ClientUser from .voice_region import VoiceRegion @@ -1433,7 +1433,7 @@ async def send( reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., - components: MessageComponentInput = ..., + components: MessageComponents = ..., poll: Poll = ..., ) -> Message: ... @@ -1454,7 +1454,7 @@ async def send( reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., - components: MessageComponentInput = ..., + components: MessageComponents = ..., poll: Poll = ..., ) -> Message: ... @@ -1475,7 +1475,7 @@ async def send( reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., - components: MessageComponentInput = ..., + components: MessageComponents = ..., poll: Poll = ..., ) -> Message: ... @@ -1496,7 +1496,7 @@ async def send( reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., view: View = ..., - components: MessageComponentInput = ..., + components: MessageComponents = ..., poll: Poll = ..., ) -> Message: ... @@ -1518,7 +1518,7 @@ async def send( reference: Optional[Union[Message, MessageReference, PartialMessage]] = None, mention_author: Optional[bool] = None, view: Optional[View] = None, - components: Optional[MessageComponentInput] = None, + components: Optional[MessageComponents] = None, poll: Optional[Poll] = None, ): """|coro| diff --git a/disnake/channel.py b/disnake/channel.py index 9509c85e6f..ab6cc022a2 100644 --- a/disnake/channel.py +++ b/disnake/channel.py @@ -95,7 +95,7 @@ from .types.soundboard import PartialSoundboardSound as PartialSoundboardSoundPayload from .types.threads import ThreadArchiveDurationLiteral from .types.voice import VoiceChannelEffect as VoiceChannelEffectPayload - from .ui._types import MessageComponentInput + from .ui._types import MessageComponents from .ui.view import View from .user import BaseUser, ClientUser, User from .voice_region import VoiceRegion @@ -3484,7 +3484,7 @@ async def create_thread( stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = ..., allowed_mentions: AllowedMentions = ..., view: View = ..., - components: MessageComponentInput = ..., + components: MessageComponents = ..., reason: Optional[str] = None, ) -> ThreadWithMessage: ... @@ -3504,7 +3504,7 @@ async def create_thread( stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = ..., allowed_mentions: AllowedMentions = ..., view: View = ..., - components: MessageComponentInput = ..., + components: MessageComponents = ..., reason: Optional[str] = None, ) -> ThreadWithMessage: ... @@ -3524,7 +3524,7 @@ async def create_thread( stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = ..., allowed_mentions: AllowedMentions = ..., view: View = ..., - components: MessageComponentInput = ..., + components: MessageComponents = ..., reason: Optional[str] = None, ) -> ThreadWithMessage: ... @@ -3544,7 +3544,7 @@ async def create_thread( stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = ..., allowed_mentions: AllowedMentions = ..., view: View = ..., - components: MessageComponentInput = ..., + components: MessageComponents = ..., reason: Optional[str] = None, ) -> ThreadWithMessage: ... @@ -3565,7 +3565,7 @@ async def create_thread( stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = MISSING, allowed_mentions: AllowedMentions = MISSING, view: View = MISSING, - components: MessageComponentInput = MISSING, + components: MessageComponents = MISSING, reason: Optional[str] = None, ) -> ThreadWithMessage: """|coro| diff --git a/disnake/interactions/base.py b/disnake/interactions/base.py index c81676df9d..cc005982c3 100644 --- a/disnake/interactions/base.py +++ b/disnake/interactions/base.py @@ -81,7 +81,7 @@ InteractionDataResolved as InteractionDataResolvedPayload, ) from ..types.snowflake import Snowflake - from ..ui._types import MessageComponentInput, ModalComponentInput + from ..ui._types import MessageComponents, ModalComponents from ..ui.modal import Modal from ..ui.view import View from .message import MessageInteraction @@ -433,7 +433,7 @@ async def edit_original_response( files: List[File] = MISSING, attachments: Optional[List[Attachment]] = MISSING, view: Optional[View] = MISSING, - components: Optional[MessageComponentInput] = MISSING, + components: Optional[MessageComponents] = MISSING, poll: Poll = MISSING, suppress_embeds: bool = MISSING, flags: MessageFlags = MISSING, @@ -675,7 +675,7 @@ async def send( files: List[File] = MISSING, allowed_mentions: AllowedMentions = MISSING, view: View = MISSING, - components: MessageComponentInput = MISSING, + components: MessageComponents = MISSING, tts: bool = False, ephemeral: bool = MISSING, suppress_embeds: bool = MISSING, @@ -959,7 +959,7 @@ async def send_message( files: List[File] = MISSING, allowed_mentions: AllowedMentions = MISSING, view: View = MISSING, - components: MessageComponentInput = MISSING, + components: MessageComponents = MISSING, tts: bool = False, ephemeral: bool = MISSING, suppress_embeds: bool = MISSING, @@ -1153,7 +1153,7 @@ async def edit_message( attachments: Optional[List[Attachment]] = MISSING, allowed_mentions: AllowedMentions = MISSING, view: Optional[View] = MISSING, - components: Optional[MessageComponentInput] = MISSING, + components: Optional[MessageComponents] = MISSING, delete_after: Optional[float] = None, ) -> None: """|coro| @@ -1393,7 +1393,7 @@ async def send_modal( *, title: str, custom_id: str, - components: ModalComponentInput, + components: ModalComponents, ) -> None: ... async def send_modal( @@ -1402,7 +1402,7 @@ async def send_modal( *, title: Optional[str] = None, custom_id: Optional[str] = None, - components: Optional[ModalComponentInput] = None, + components: Optional[ModalComponents] = None, ) -> None: """|coro| @@ -1644,7 +1644,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[MessageComponentInput] = ..., + components: Optional[MessageComponents] = ..., delete_after: Optional[float] = ..., ) -> InteractionMessage: ... @@ -1660,7 +1660,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[MessageComponentInput] = ..., + components: Optional[MessageComponents] = ..., delete_after: Optional[float] = ..., ) -> InteractionMessage: ... @@ -1676,7 +1676,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[MessageComponentInput] = ..., + components: Optional[MessageComponents] = ..., delete_after: Optional[float] = ..., ) -> InteractionMessage: ... @@ -1692,7 +1692,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[MessageComponentInput] = ..., + components: Optional[MessageComponents] = ..., delete_after: Optional[float] = ..., ) -> InteractionMessage: ... @@ -1709,7 +1709,7 @@ async def edit( flags: MessageFlags = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, view: Optional[View] = MISSING, - components: Optional[MessageComponentInput] = MISSING, + components: Optional[MessageComponents] = MISSING, delete_after: Optional[float] = None, ) -> Message: """|coro| diff --git a/disnake/message.py b/disnake/message.py index 108ddc200d..a6d84af130 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -90,7 +90,7 @@ ) from .types.threads import ThreadArchiveDurationLiteral from .types.user import User as UserPayload - from .ui._types import MessageComponentInput + from .ui._types import MessageComponents from .ui.view import View EmojiInputType = Union[Emoji, PartialEmoji, str] @@ -152,7 +152,7 @@ async def _edit_handler( flags: MessageFlags, allowed_mentions: Optional[AllowedMentions], view: Optional[View], - components: Optional[MessageComponentInput], + components: Optional[MessageComponents], ) -> Message: if embed is not MISSING and embeds is not MISSING: raise TypeError("Cannot mix embed and embeds keyword arguments.") @@ -1868,7 +1868,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[MessageComponentInput] = ..., + components: Optional[MessageComponents] = ..., delete_after: Optional[float] = ..., ) -> Message: ... @@ -1884,7 +1884,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[MessageComponentInput] = ..., + components: Optional[MessageComponents] = ..., delete_after: Optional[float] = ..., ) -> Message: ... @@ -1900,7 +1900,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[MessageComponentInput] = ..., + components: Optional[MessageComponents] = ..., delete_after: Optional[float] = ..., ) -> Message: ... @@ -1916,7 +1916,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[MessageComponentInput] = ..., + components: Optional[MessageComponents] = ..., delete_after: Optional[float] = ..., ) -> Message: ... @@ -1934,7 +1934,7 @@ async def edit( flags: MessageFlags = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, view: Optional[View] = MISSING, - components: Optional[MessageComponentInput] = MISSING, + components: Optional[MessageComponents] = MISSING, delete_after: Optional[float] = None, ) -> Message: """|coro| @@ -2631,7 +2631,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[MessageComponentInput] = ..., + components: Optional[MessageComponents] = ..., delete_after: Optional[float] = ..., ) -> Message: ... @@ -2647,7 +2647,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[MessageComponentInput] = ..., + components: Optional[MessageComponents] = ..., delete_after: Optional[float] = ..., ) -> Message: ... @@ -2663,7 +2663,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[MessageComponentInput] = ..., + components: Optional[MessageComponents] = ..., delete_after: Optional[float] = ..., ) -> Message: ... @@ -2679,7 +2679,7 @@ async def edit( flags: MessageFlags = ..., allowed_mentions: Optional[AllowedMentions] = ..., view: Optional[View] = ..., - components: Optional[MessageComponentInput] = ..., + components: Optional[MessageComponents] = ..., delete_after: Optional[float] = ..., ) -> Message: ... @@ -2697,7 +2697,7 @@ async def edit( flags: MessageFlags = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, view: Optional[View] = MISSING, - components: Optional[MessageComponentInput] = MISSING, + components: Optional[MessageComponents] = MISSING, delete_after: Optional[float] = None, ) -> Message: """|coro| diff --git a/disnake/ui/_types.py b/disnake/ui/_types.py index 0f71fbbde3..a5ee800603 100644 --- a/disnake/ui/_types.py +++ b/disnake/ui/_types.py @@ -73,5 +73,5 @@ ], ] -MessageComponentInput = ComponentInput[ActionRowMessageComponent, MessageTopLevelComponentV2] -ModalComponentInput = ComponentInput[ActionRowModalComponent, NoReturn] +MessageComponents = ComponentInput[ActionRowMessageComponent, MessageTopLevelComponentV2] +ModalComponents = ComponentInput[ActionRowModalComponent, NoReturn] diff --git a/disnake/ui/modal.py b/disnake/ui/modal.py index f90845aacc..f9b90cd5a1 100644 --- a/disnake/ui/modal.py +++ b/disnake/ui/modal.py @@ -19,7 +19,7 @@ from ..interactions.modal import ModalInteraction from ..state import ConnectionState from ..types.components import Modal as ModalPayload - from ..ui._types import ModalComponentInput + from ..ui._types import ModalComponents __all__ = ("Modal",) @@ -70,7 +70,7 @@ def __init__( self, *, title: str, - components: ModalComponentInput, + components: ModalComponents, custom_id: str = MISSING, timeout: float = 600, ) -> None: diff --git a/disnake/webhook/async_.py b/disnake/webhook/async_.py index 3e9a3468e5..1d78117570 100644 --- a/disnake/webhook/async_.py +++ b/disnake/webhook/async_.py @@ -68,7 +68,7 @@ from ..sticker import GuildSticker, StandardSticker, StickerItem from ..types.message import Message as MessagePayload from ..types.webhook import Webhook as WebhookPayload - from ..ui._types import MessageComponentInput + from ..ui._types import MessageComponents from ..ui.view import View MISSING = utils.MISSING @@ -508,7 +508,7 @@ def handle_message_parameters_dict( embed: Optional[Embed] = MISSING, embeds: List[Embed] = MISSING, view: Optional[View] = MISSING, - components: Optional[MessageComponentInput] = MISSING, + components: Optional[MessageComponents] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, previous_allowed_mentions: Optional[AllowedMentions] = None, stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = MISSING, @@ -602,7 +602,7 @@ def handle_message_parameters( embed: Optional[Embed] = MISSING, embeds: List[Embed] = MISSING, view: Optional[View] = MISSING, - components: Optional[MessageComponentInput] = MISSING, + components: Optional[MessageComponents] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, previous_allowed_mentions: Optional[AllowedMentions] = None, stickers: Sequence[Union[GuildSticker, StandardSticker, StickerItem]] = MISSING, @@ -789,7 +789,7 @@ async def edit( files: List[File] = MISSING, attachments: Optional[List[Attachment]] = MISSING, view: Optional[View] = MISSING, - components: Optional[MessageComponentInput] = MISSING, + components: Optional[MessageComponents] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, ) -> WebhookMessage: """|coro| @@ -1500,7 +1500,7 @@ async def send( embeds: List[Embed] = ..., allowed_mentions: AllowedMentions = ..., view: View = ..., - components: MessageComponentInput = ..., + components: MessageComponents = ..., poll: Poll = ..., thread: Snowflake = ..., thread_name: str = ..., @@ -1526,7 +1526,7 @@ async def send( embeds: List[Embed] = ..., allowed_mentions: AllowedMentions = ..., view: View = ..., - components: MessageComponentInput = ..., + components: MessageComponents = ..., poll: Poll = ..., thread: Snowflake = ..., thread_name: str = ..., @@ -1551,7 +1551,7 @@ async def send( embeds: List[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, view: View = MISSING, - components: MessageComponentInput = MISSING, + components: MessageComponents = MISSING, thread: Snowflake = MISSING, thread_name: str = MISSING, applied_tags: Sequence[Snowflake] = MISSING, @@ -1853,7 +1853,7 @@ async def edit_message( files: List[File] = MISSING, attachments: Optional[List[Attachment]] = MISSING, view: Optional[View] = MISSING, - components: Optional[MessageComponentInput] = MISSING, + components: Optional[MessageComponents] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, thread: Optional[Snowflake] = None, ) -> WebhookMessage: From 95ac09450ac53b23f2abecbde0e653552bd4d07f Mon Sep 17 00:00:00 2001 From: shiftinv Date: Mon, 9 Jun 2025 12:12:40 +0200 Subject: [PATCH 035/104] chore(typing): declare `__repr_attributes__` in components as classvars Co-authored-by: Eneg <42005170+Enegg@users.noreply.github.com> --- disnake/ui/action_row.py | 3 ++- disnake/ui/button.py | 3 ++- disnake/ui/container.py | 4 ++-- disnake/ui/file.py | 4 ++-- disnake/ui/item.py | 5 +++-- disnake/ui/media_gallery.py | 4 ++-- disnake/ui/section.py | 4 ++-- disnake/ui/select/base.py | 2 +- disnake/ui/select/channel.py | 4 +++- disnake/ui/select/string.py | 2 +- disnake/ui/separator.py | 4 ++-- disnake/ui/text_display.py | 4 ++-- disnake/ui/text_input.py | 4 ++-- disnake/ui/thumbnail.py | 4 ++-- 14 files changed, 28 insertions(+), 23 deletions(-) diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index 6f14a85bf2..b6b079fb17 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -4,6 +4,7 @@ from typing import ( TYPE_CHECKING, + ClassVar, Generator, Generic, Iterator, @@ -158,7 +159,7 @@ class ActionRow(UIComponent, Generic[ActionRowChildT]): """ # unused, but technically required by base type - __repr_attributes__: Tuple[str, ...] = ("children",) + __repr_attributes__: ClassVar[Tuple[str, ...]] = ("children",) # When unspecified and called empty, default to an ActionRow that takes any kind of component. diff --git a/disnake/ui/button.py b/disnake/ui/button.py index d1134b8e45..e28a6eff1a 100644 --- a/disnake/ui/button.py +++ b/disnake/ui/button.py @@ -8,6 +8,7 @@ TYPE_CHECKING, Any, Callable, + ClassVar, Optional, Tuple, TypeVar, @@ -75,7 +76,7 @@ class Button(Item[V_co]): ordering. The row number must be between 0 and 4 (i.e. zero indexed). """ - __repr_attributes__: Tuple[str, ...] = ( + __repr_attributes__: ClassVar[Tuple[str, ...]] = ( "style", "url", "disabled", diff --git a/disnake/ui/container.py b/disnake/ui/container.py index ed00630064..80ddb0f3e2 100644 --- a/disnake/ui/container.py +++ b/disnake/ui/container.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, ClassVar, List, Optional, Sequence, Tuple, Union from ..colour import Colour from ..components import Container as ContainerComponent @@ -55,7 +55,7 @@ class Container(UIComponent): """ # unused, but technically required by base type - __repr_attributes__: Tuple[str, ...] = ( + __repr_attributes__: ClassVar[Tuple[str, ...]] = ( "components", "accent_colour", "spoiler", diff --git a/disnake/ui/file.py b/disnake/ui/file.py index dedca9ba98..4dedc9f8f6 100644 --- a/disnake/ui/file.py +++ b/disnake/ui/file.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Tuple +from typing import Any, ClassVar, Tuple from ..components import FileComponent from ..enums import ComponentType @@ -25,7 +25,7 @@ class File(UIComponent): Whether the file is marked as a spoiler. Defaults to ``False``. """ - __repr_attributes__: Tuple[str, ...] = ( + __repr_attributes__: ClassVar[Tuple[str, ...]] = ( "file", "spoiler", ) diff --git a/disnake/ui/item.py b/disnake/ui/item.py index d3bcb68442..8d7c3391f0 100644 --- a/disnake/ui/item.py +++ b/disnake/ui/item.py @@ -7,6 +7,7 @@ TYPE_CHECKING, Any, Callable, + ClassVar, Coroutine, Dict, Generic, @@ -69,7 +70,7 @@ class UIComponent(ABC): .. versionadded:: 2.11 """ - __repr_attributes__: Tuple[str, ...] + __repr_attributes__: ClassVar[Tuple[str, ...]] @property @abstractmethod @@ -132,7 +133,7 @@ class Item(WrappedComponent, Generic[V_co]): .. versionadded:: 2.0 """ - __repr_attributes__: Tuple[str, ...] = ("row",) + __repr_attributes__: ClassVar[Tuple[str, ...]] = ("row",) @overload def __init__(self: Item[None]) -> None: ... diff --git a/disnake/ui/media_gallery.py b/disnake/ui/media_gallery.py index 9653c63115..9d919b1e0b 100644 --- a/disnake/ui/media_gallery.py +++ b/disnake/ui/media_gallery.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import List, Sequence, Tuple +from typing import ClassVar, List, Sequence, Tuple from ..components import MediaGallery as MediaGalleryComponent, MediaGalleryItem from ..enums import ComponentType @@ -23,7 +23,7 @@ class MediaGallery(UIComponent): The list of images in this gallery. """ - __repr_attributes__: Tuple[str, ...] = ("items",) + __repr_attributes__: ClassVar[Tuple[str, ...]] = ("items",) # We have to set this to MISSING in order to overwrite the abstract property from UIComponent _underlying: MediaGalleryComponent = MISSING diff --git a/disnake/ui/section.py b/disnake/ui/section.py index 7d93cf0a9c..1573d1a616 100644 --- a/disnake/ui/section.py +++ b/disnake/ui/section.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List, Sequence, Tuple, Union +from typing import TYPE_CHECKING, ClassVar, List, Sequence, Tuple, Union from ..components import Section as SectionComponent from ..enums import ComponentType @@ -31,7 +31,7 @@ class Section(UIComponent): """ # unused, but technically required by base type - __repr_attributes__: Tuple[str, ...] = ( + __repr_attributes__: ClassVar[Tuple[str, ...]] = ( "components", "accessory", ) diff --git a/disnake/ui/select/base.py b/disnake/ui/select/base.py index b7897bedae..09a7e7f1b5 100644 --- a/disnake/ui/select/base.py +++ b/disnake/ui/select/base.py @@ -67,7 +67,7 @@ class BaseSelect(Generic[SelectMenuT, SelectValueT, V_co], Item[V_co], ABC): .. versionadded:: 2.7 """ - __repr_attributes__: Tuple[str, ...] = ( + __repr_attributes__: ClassVar[Tuple[str, ...]] = ( "placeholder", "min_values", "max_values", diff --git a/disnake/ui/select/channel.py b/disnake/ui/select/channel.py index 743ee0acfa..e0f30888dd 100644 --- a/disnake/ui/select/channel.py +++ b/disnake/ui/select/channel.py @@ -84,7 +84,9 @@ class ChannelSelect(BaseSelect[ChannelSelectMenu, "AnyChannel", V_co]): A list of channels that have been selected by the user. """ - __repr_attributes__: Tuple[str, ...] = BaseSelect.__repr_attributes__ + ("channel_types",) + __repr_attributes__: ClassVar[Tuple[str, ...]] = BaseSelect.__repr_attributes__ + ( + "channel_types", + ) _default_value_type_map: ClassVar[ Mapping[SelectDefaultValueType, Tuple[Type[Snowflake], ...]] diff --git a/disnake/ui/select/string.py b/disnake/ui/select/string.py index ae97a19aa4..4141e07382 100644 --- a/disnake/ui/select/string.py +++ b/disnake/ui/select/string.py @@ -99,7 +99,7 @@ class StringSelect(BaseSelect[StringSelectMenu, str, V_co]): A list of values that have been selected by the user. """ - __repr_attributes__: Tuple[str, ...] = BaseSelect.__repr_attributes__ + ("options",) + __repr_attributes__: ClassVar[Tuple[str, ...]] = BaseSelect.__repr_attributes__ + ("options",) # In practice this should never be used by anything, might as well have it anyway though. _default_value_type_map: ClassVar[ diff --git a/disnake/ui/separator.py b/disnake/ui/separator.py index 02969e8835..79f5d05e66 100644 --- a/disnake/ui/separator.py +++ b/disnake/ui/separator.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Tuple +from typing import ClassVar, Tuple from ..components import Separator as SeparatorComponent from ..enums import ComponentType, SeparatorSpacingSize @@ -27,7 +27,7 @@ class Separator(UIComponent): Defaults to :attr:`~.SeparatorSpacingSize.small`. """ - __repr_attributes__: Tuple[str, ...] = ( + __repr_attributes__: ClassVar[Tuple[str, ...]] = ( "divider", "spacing", ) diff --git a/disnake/ui/text_display.py b/disnake/ui/text_display.py index ccd6f38424..8b165475e0 100644 --- a/disnake/ui/text_display.py +++ b/disnake/ui/text_display.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Tuple +from typing import ClassVar, Tuple from ..components import TextDisplay as TextDisplayComponent from ..enums import ComponentType @@ -24,7 +24,7 @@ class TextDisplay(UIComponent): The text displayed by this component. """ - __repr_attributes__: Tuple[str, ...] = ("content",) + __repr_attributes__: ClassVar[Tuple[str, ...]] = ("content",) # We have to set this to MISSING in order to overwrite the abstract property from UIComponent _underlying: TextDisplayComponent = MISSING diff --git a/disnake/ui/text_input.py b/disnake/ui/text_input.py index 81a88e0fe6..066dc0e04c 100644 --- a/disnake/ui/text_input.py +++ b/disnake/ui/text_input.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Optional, Tuple +from typing import ClassVar, Optional, Tuple from ..components import TextInput as TextInputComponent from ..enums import ComponentType, TextInputStyle @@ -39,7 +39,7 @@ class TextInput(WrappedComponent): The maximum length of the text input. """ - __repr_attributes__: Tuple[str, ...] = ( + __repr_attributes__: ClassVar[Tuple[str, ...]] = ( "style", "label", "custom_id", diff --git a/disnake/ui/thumbnail.py b/disnake/ui/thumbnail.py index 5f37781ccc..16e4aee165 100644 --- a/disnake/ui/thumbnail.py +++ b/disnake/ui/thumbnail.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Optional, Tuple +from typing import Any, ClassVar, Optional, Tuple from ..components import Thumbnail as ThumbnailComponent from ..enums import ComponentType @@ -29,7 +29,7 @@ class Thumbnail(UIComponent): Whether the thumbnail is marked as a spoiler. Defaults to ``False``. """ - __repr_attributes__: Tuple[str, ...] = ( + __repr_attributes__: ClassVar[Tuple[str, ...]] = ( "media", "description", "spoiler", From 3dd1aa735d8ce90b830a8aa94f63b50c267bf36f Mon Sep 17 00:00:00 2001 From: shiftinv Date: Mon, 9 Jun 2025 12:18:27 +0200 Subject: [PATCH 036/104] chore: clarify name of action row child type tuple --- disnake/components.py | 5 +++-- disnake/interactions/message.py | 4 ++-- disnake/ui/view.py | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index b59a5e41b7..bc11face0d 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -1161,8 +1161,9 @@ def accent_color(self) -> Optional[Colour]: return self.accent_colour -# see ActionRowMessageComponent -VALID_ACTION_ROW_MESSAGE_TYPES: Final = ( +# types of components that are allowed in a message's action rows; +# see also `ActionRowMessageComponent` type alias +VALID_ACTION_ROW_MESSAGE_COMPONENT_TYPES: Final = ( Button, StringSelectMenu, UserSelectMenu, diff --git a/disnake/interactions/message.py b/disnake/interactions/message.py index 2e5f88528f..14dce47da3 100644 --- a/disnake/interactions/message.py +++ b/disnake/interactions/message.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union from ..components import ( - VALID_ACTION_ROW_MESSAGE_TYPES, + VALID_ACTION_ROW_MESSAGE_COMPONENT_TYPES, ActionRowMessageComponent, _walk_all_components, ) @@ -175,7 +175,7 @@ def component(self) -> ActionRowMessageComponent: # FIXME(3.0?): introduce common base type for components with `custom_id` for component in _walk_all_components(self.message.components): if ( - isinstance(component, VALID_ACTION_ROW_MESSAGE_TYPES) + isinstance(component, VALID_ACTION_ROW_MESSAGE_COMPONENT_TYPES) and component.custom_id == self.data.custom_id ): return component diff --git a/disnake/ui/view.py b/disnake/ui/view.py index cdbf330376..2242236e5a 100644 --- a/disnake/ui/view.py +++ b/disnake/ui/view.py @@ -22,7 +22,7 @@ ) from ..components import ( - VALID_ACTION_ROW_MESSAGE_TYPES, + VALID_ACTION_ROW_MESSAGE_COMPONENT_TYPES, ActionRow as ActionRowComponent, ActionRowMessageComponent, Button as ButtonComponent, @@ -232,7 +232,7 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) for component in _walk_all_components(message.components): if isinstance(component, ActionRowComponent): continue - elif not isinstance(component, VALID_ACTION_ROW_MESSAGE_TYPES): + elif not isinstance(component, VALID_ACTION_ROW_MESSAGE_COMPONENT_TYPES): # can happen if message uses components v2 raise TypeError( f"Cannot construct view from message - unexpected {type(component).__name__}" From 61cb394457200a821cb245225d92a0638e3130ec Mon Sep 17 00:00:00 2001 From: shiftinv Date: Mon, 9 Jun 2025 12:21:53 +0200 Subject: [PATCH 037/104] refactor: try/catch component_factory --- disnake/components.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index bc11face0d..4d0b4242d7 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -1226,11 +1226,14 @@ def _walk_all_components(components: Sequence[Component]) -> Iterator[Component] def _component_factory(data: ComponentPayload, *, type: Type[C] = Component) -> C: component_type = data["type"] - if component_cls := COMPONENT_LOOKUP.get(component_type): - return component_cls(data) # type: ignore - else: + try: + component_cls = COMPONENT_LOOKUP[component_type] + except KeyError: + # if we encounter an unknown component type, just construct a placeholder component for it as_enum = try_enum(ComponentType, component_type) return Component._raw_construct(type=as_enum) # type: ignore + else: + return component_cls(data) # type: ignore # this is just a rebranded _component_factory, From 567a6855a2451ab424aea3d21907a33696418b27 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Mon, 9 Jun 2025 12:42:53 +0200 Subject: [PATCH 038/104] fix(types): `typing.TypeAlias` is 3.10+ --- disnake/types/components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/disnake/types/components.py b/disnake/types/components.py index 3b4820fb23..6b98380d00 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -2,9 +2,9 @@ from __future__ import annotations -from typing import List, Literal, TypeAlias, TypedDict, Union +from typing import List, Literal, TypedDict, Union -from typing_extensions import NotRequired +from typing_extensions import NotRequired, TypeAlias from .channel import ChannelType from .emoji import PartialEmoji From 23ac2e1c99c4f0a7630e61267f48acfb137856a3 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 11 Jun 2025 13:47:48 +0200 Subject: [PATCH 039/104] perf: reduce `_message_component_factory` call overhead Co-authored-by: Eneg <42005170+Enegg@users.noreply.github.com> --- disnake/components.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 4d0b4242d7..809781870e 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -1236,8 +1236,12 @@ def _component_factory(data: ComponentPayload, *, type: Type[C] = Component) -> return component_cls(data) # type: ignore -# this is just a rebranded _component_factory, -# as a workaround to Python not supporting typescript-like mapped types -# XXX: an alternative would be declaring 14 _component_factory overloads, which also isn't too great. -def _message_component_factory(data: MessageTopLevelComponentPayload) -> MessageTopLevelComponent: - return _component_factory(data) # type: ignore +# this is just a rebranded _component_factory, as a workaround to Python not supporting typescript-like mapped types +if TYPE_CHECKING: + + def _message_component_factory( + data: MessageTopLevelComponentPayload, + ) -> MessageTopLevelComponent: ... + +else: + _message_component_factory = _component_factory From 7bee2ce07cf710b8ec90914e62a69caf7c278923 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 11 Jun 2025 14:07:54 +0200 Subject: [PATCH 040/104] fix: support reused components in `_walk_all_components` --- disnake/components.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/disnake/components.py b/disnake/components.py index 809781870e..8d38cc5a58 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -1177,7 +1177,9 @@ def _walk_internal(component: Component, seen: Set[Component]) -> Iterator[Compo if component in seen: # prevent infinite recursion if anyone manages to nest a component in itself return - seen.add(component) + # add current component, while also creating a copy to allow reusing a component multiple times, + # as long as it's not within itself + seen = {*seen, component} yield component From b400868df066e825007db88960adb63db482b70f Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 16 Jul 2025 17:17:49 +0200 Subject: [PATCH 041/104] chore: update comment re PEP 705 in component types --- disnake/types/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/disnake/types/components.py b/disnake/types/components.py index 6b98380d00..0943a27750 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -55,7 +55,7 @@ class _BaseComponent(TypedDict): - # type: ComponentType # FIXME: current version of pyright complains about overriding types, latest might be fine + # type: ComponentType # FIXME: current version of pyright only supports PEP 705 experimentally, this can be re-enabled in 1.1.353+ # TODO: always present in responses id: NotRequired[int] # NOTE: not implemented (yet?) From dfa793ea1daef9f862c8de4a3eadc0fd5f2b31f2 Mon Sep 17 00:00:00 2001 From: vi <8530778+shiftinv@users.noreply.github.com> Date: Wed, 16 Jul 2025 17:21:51 +0200 Subject: [PATCH 042/104] docs: update SeparatorSpacingSize member docstrings Co-authored-by: Eneg <42005170+Enegg@users.noreply.github.com> Signed-off-by: vi <8530778+shiftinv@users.noreply.github.com> --- disnake/enums.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/disnake/enums.py b/disnake/enums.py index 49f102908d..12141c9c29 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -2363,9 +2363,9 @@ class SeparatorSpacingSize(Enum): """ small = 1 - """TODO""" + """Small spacing.""" large = 2 - """TODO""" + """Large spacing.""" T = TypeVar("T") From 8f442faeaaa5ae50718dc964cc7a551a1d014f9d Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 16 Jul 2025 17:38:09 +0200 Subject: [PATCH 043/104] fix: remove undocumented `MessageInteractionData.id` --- disnake/interactions/message.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/disnake/interactions/message.py b/disnake/interactions/message.py index 14dce47da3..67512de8ea 100644 --- a/disnake/interactions/message.py +++ b/disnake/interactions/message.py @@ -25,7 +25,6 @@ from ..role import Role from ..state import ConnectionState from ..types.interactions import ( - InteractionDataResolved as InteractionDataResolvedPayload, MessageComponentInteractionData as MessageComponentInteractionDataPayload, MessageInteraction as MessageInteractionPayload, ) @@ -192,10 +191,6 @@ class MessageInteractionData(Dict[str, Any]): ---------- custom_id: :class:`str` The custom ID of the component. - id: :class:`int` - TODO - - .. versionadded:: 2.11 component_type: :class:`ComponentType` The type of the component. values: Optional[List[:class:`str`]] @@ -207,7 +202,7 @@ class MessageInteractionData(Dict[str, Any]): .. versionadded:: 2.7 """ - __slots__ = ("custom_id", "id", "component_type", "values", "resolved") + __slots__ = ("custom_id", "component_type", "values", "resolved") def __init__( self, @@ -217,16 +212,12 @@ def __init__( ) -> None: super().__init__(data) self.custom_id: str = data["custom_id"] - self.id: int = data.get("id") self.component_type: ComponentType = try_enum(ComponentType, data["component_type"]) self.values: Optional[List[str]] = ( list(map(str, values)) if (values := data.get("values")) else None ) - empty_resolved: InteractionDataResolvedPayload = {} # pyright shenanigans - self.resolved = InteractionDataResolved( - data=data.get("resolved", empty_resolved), parent=parent - ) + self.resolved = InteractionDataResolved(data=data.get("resolved", {}), parent=parent) def __repr__(self) -> str: return ( From a5246266cd7bdf2d2da9930fa77a1e0169a6a3f1 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 16 Jul 2025 17:44:14 +0200 Subject: [PATCH 044/104] feat: strip leading underscores in `ui.Item` repr keys Co-authored-by: Eneg <42005170+Enegg@users.noreply.github.com> --- disnake/ui/action_row.py | 7 +------ disnake/ui/container.py | 7 +------ disnake/ui/item.py | 4 +++- disnake/ui/section.py | 7 +------ 4 files changed, 6 insertions(+), 19 deletions(-) diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index b6b079fb17..a732d6eafa 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -158,8 +158,7 @@ class ActionRow(UIComponent, Generic[ActionRowChildT]): context of a modal. Combining components from both contexts is not supported. """ - # unused, but technically required by base type - __repr_attributes__: ClassVar[Tuple[str, ...]] = ("children",) + __repr_attributes__: ClassVar[Tuple[str, ...]] = ("_children",) # When unspecified and called empty, default to an ActionRow that takes any kind of component. @@ -195,10 +194,6 @@ def __init__(self, *components: WrappedComponent) -> None: ) self.append_item(component) # type: ignore - def __repr__(self) -> str: - # implemented separately for now, due to SequenceProxy repr - return f"" - # FIXME(3.0)?: `bool(ActionRow())` returns False, which may be undesired def __len__(self) -> int: return len(self._children) diff --git a/disnake/ui/container.py b/disnake/ui/container.py index 80ddb0f3e2..2ec1d6ab69 100644 --- a/disnake/ui/container.py +++ b/disnake/ui/container.py @@ -54,9 +54,8 @@ class Container(UIComponent): Whether the container is marked as a spoiler. """ - # unused, but technically required by base type __repr_attributes__: ClassVar[Tuple[str, ...]] = ( - "components", + "_components", "accent_colour", "spoiler", ) @@ -81,10 +80,6 @@ def components(self) -> Sequence[ContainerChildUIComponent]: """Sequence[Union[:class:`~.ui.ActionRow`, :class:`~.ui.Section`, :class:`~.ui.TextDisplay`, :class:`~.ui.MediaGallery`, :class:`~.ui.File`, :class:`~.ui.Separator`]]: A read-only copy of the components in this container.""" return SequenceProxy(self._components) - def __repr__(self) -> str: - # implemented separately for now, due to SequenceProxy repr - return f"" - @property def _underlying(self) -> ContainerComponent: return ContainerComponent._raw_construct( diff --git a/disnake/ui/item.py b/disnake/ui/item.py index 8d7c3391f0..9ef86b81b7 100644 --- a/disnake/ui/item.py +++ b/disnake/ui/item.py @@ -77,7 +77,9 @@ class UIComponent(ABC): def _underlying(self) -> Component: ... def __repr__(self) -> str: - attrs = " ".join(f"{key}={getattr(self, key)!r}" for key in self.__repr_attributes__) + attrs = " ".join( + f"{key.lstrip('_')}={getattr(self, key)!r}" for key in self.__repr_attributes__ + ) return f"<{type(self).__name__} {attrs}>" @property diff --git a/disnake/ui/section.py b/disnake/ui/section.py index 1573d1a616..79bb6344bb 100644 --- a/disnake/ui/section.py +++ b/disnake/ui/section.py @@ -30,9 +30,8 @@ class Section(UIComponent): The accessory component displayed next to the section text. """ - # unused, but technically required by base type __repr_attributes__: ClassVar[Tuple[str, ...]] = ( - "components", + "_components", "accessory", ) @@ -58,10 +57,6 @@ def accessory(self) -> Union[Thumbnail, Button]: """Union[:class:`~.ui.Thumbnail`, :class:`~.ui.Button`]: The accessory component displayed next to the section text.""" return self._accessory - def __repr__(self) -> str: - # implemented separately for now, due to SequenceProxy repr - return f"
" - @property def _underlying(self) -> SectionComponent: return SectionComponent._raw_construct( From c108dd30daa2206c43a6fa0efc5e05eb78485224 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 16 Jul 2025 17:49:02 +0200 Subject: [PATCH 045/104] fix: it's not a bug, it's a feature (empty action rows are now meant to be falsy) --- disnake/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index a732d6eafa..d716bec5c6 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -128,6 +128,7 @@ class ActionRow(UIComponent, Generic[ActionRowChildT]): .. describe:: len(x) Returns the number of components in this row. + Note that this means empty rows will be considered falsy. .. versionadded:: 2.6 @@ -194,7 +195,6 @@ def __init__(self, *components: WrappedComponent) -> None: ) self.append_item(component) # type: ignore - # FIXME(3.0)?: `bool(ActionRow())` returns False, which may be undesired def __len__(self) -> int: return len(self._children) From b3a489459504be9f134e704743992c58daeb689e Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 16 Jul 2025 17:51:48 +0200 Subject: [PATCH 046/104] fix(typing): add missing `[Any]` to buttons in sections --- disnake/ui/section.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/disnake/ui/section.py b/disnake/ui/section.py index 79bb6344bb..497a8b8722 100644 --- a/disnake/ui/section.py +++ b/disnake/ui/section.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar, List, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Any, ClassVar, List, Sequence, Tuple, Union from ..components import Section as SectionComponent from ..enums import ComponentType @@ -39,12 +39,12 @@ class Section(UIComponent): def __init__( self, *components: TextDisplay, - accessory: Union[Thumbnail, Button], + accessory: Union[Thumbnail, Button[Any]], ) -> None: self._components: List[TextDisplay] = [ ensure_ui_component(c, "components") for c in components ] - self._accessory: Union[Thumbnail, Button] = ensure_ui_component(accessory, "accessory") + self._accessory: Union[Thumbnail, Button[Any]] = ensure_ui_component(accessory, "accessory") # TODO: consider moving runtime checks from constructor into property setters, also making these fields writable @property @@ -53,7 +53,7 @@ def components(self) -> Sequence[TextDisplay]: return SequenceProxy(self._components) @property - def accessory(self) -> Union[Thumbnail, Button]: + def accessory(self) -> Union[Thumbnail, Button[Any]]: """Union[:class:`~.ui.Thumbnail`, :class:`~.ui.Button`]: The accessory component displayed next to the section text.""" return self._accessory From a308569f0df767faa05415acf26b11f05bd41758 Mon Sep 17 00:00:00 2001 From: vi <8530778+shiftinv@users.noreply.github.com> Date: Wed, 16 Jul 2025 18:03:10 +0200 Subject: [PATCH 047/104] chore(docs): copy -> proxy Co-authored-by: Eneg <42005170+Enegg@users.noreply.github.com> Signed-off-by: vi <8530778+shiftinv@users.noreply.github.com> --- disnake/ui/action_row.py | 2 +- disnake/ui/container.py | 2 +- disnake/ui/section.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index d716bec5c6..8a6898a76d 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -201,7 +201,7 @@ def __len__(self) -> int: @property def children(self) -> Sequence[ActionRowChildT]: """Sequence[:class:`WrappedComponent`]: - A read-only copy of the UI components stored in this action row. To add/remove + A read-only proxy of the UI components stored in this action row. To add/remove components to/from the action row, use its methods to directly modify it. .. versionchanged:: 2.6 diff --git a/disnake/ui/container.py b/disnake/ui/container.py index 2ec1d6ab69..2126e515d2 100644 --- a/disnake/ui/container.py +++ b/disnake/ui/container.py @@ -77,7 +77,7 @@ def __init__( # TODO: consider moving runtime checks from constructor into property setters, also making these fields writable @property def components(self) -> Sequence[ContainerChildUIComponent]: - """Sequence[Union[:class:`~.ui.ActionRow`, :class:`~.ui.Section`, :class:`~.ui.TextDisplay`, :class:`~.ui.MediaGallery`, :class:`~.ui.File`, :class:`~.ui.Separator`]]: A read-only copy of the components in this container.""" + """Sequence[Union[:class:`~.ui.ActionRow`, :class:`~.ui.Section`, :class:`~.ui.TextDisplay`, :class:`~.ui.MediaGallery`, :class:`~.ui.File`, :class:`~.ui.Separator`]]: A read-only proxy of the components in this container.""" return SequenceProxy(self._components) @property diff --git a/disnake/ui/section.py b/disnake/ui/section.py index 497a8b8722..ef1fda9c72 100644 --- a/disnake/ui/section.py +++ b/disnake/ui/section.py @@ -49,7 +49,7 @@ def __init__( # TODO: consider moving runtime checks from constructor into property setters, also making these fields writable @property def components(self) -> Sequence[TextDisplay]: - """Sequence[:class:`~.ui.TextDisplay`]: A read-only copy of the text items in this section.""" + """Sequence[:class:`~.ui.TextDisplay`]: A read-only proxy of the text items in this section.""" return SequenceProxy(self._components) @property From a362a748973cf34a32dcd4724f0000ebd6cdccbc Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 16 Jul 2025 18:07:01 +0200 Subject: [PATCH 048/104] docs: finish list of `Component` subclasses --- disnake/components.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 8d38cc5a58..c1cfa1dca5 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -144,17 +144,21 @@ class Component: - """Represents a Discord Bot UI Kit Component. + """Represents the base component that all other components inherit from. - Currently, the only components supported by Discord are: + The components supported by Discord are: - :class:`ActionRow` - :class:`Button` - subtypes of :class:`BaseSelectMenu` (:class:`ChannelSelectMenu`, :class:`MentionableSelectMenu`, :class:`RoleSelectMenu`, :class:`StringSelectMenu`, :class:`UserSelectMenu`) - :class:`TextInput` - - .. - TODO: add cv2 components to list + - :class:`Section` + - :class:`TextDisplay` + - :class:`Thumbnail` + - :class:`MediaGallery` + - :class:`FileComponent` + - :class:`Separator` + - :class:`Container` This class is abstract and cannot be instantiated. From 12da63dd5ac4be514bc4f7e05ddb85c65f1f422a Mon Sep 17 00:00:00 2001 From: vi <8530778+shiftinv@users.noreply.github.com> Date: Thu, 7 Aug 2025 11:28:08 +0200 Subject: [PATCH 049/104] refactor: make primary `ui.Thumbnail` parameters positional Co-authored-by: Eneg <42005170+Enegg@users.noreply.github.com> Signed-off-by: vi <8530778+shiftinv@users.noreply.github.com> --- disnake/ui/thumbnail.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/disnake/ui/thumbnail.py b/disnake/ui/thumbnail.py index 16e4aee165..3cb03835bf 100644 --- a/disnake/ui/thumbnail.py +++ b/disnake/ui/thumbnail.py @@ -39,9 +39,9 @@ class Thumbnail(UIComponent): def __init__( self, - *, - media: Any, # XXX: positional? + media: Any, description: Optional[str] = None, + *, spoiler: bool = False, ) -> None: self._underlying = ThumbnailComponent._raw_construct( From 9978a044f989952488263fbf99584132b3410615 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Thu, 7 Aug 2025 16:48:00 +0200 Subject: [PATCH 050/104] feat: implement `UnfurledMediaItem` --- disnake/components.py | 76 ++++++++++++++++++++++++++++++++++--- disnake/types/components.py | 18 ++++++--- disnake/ui/_types.py | 5 +++ disnake/ui/file.py | 26 +++++++------ disnake/ui/item.py | 10 +++++ disnake/ui/thumbnail.py | 25 ++++++------ docs/api/components.rst | 10 +++++ 7 files changed, 136 insertions(+), 34 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index c1cfa1dca5..8fa87a4cb0 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -23,6 +23,7 @@ cast, ) +from .asset import AssetMixin from .colour import Colour from .enums import ( ButtonStyle, @@ -63,6 +64,7 @@ TextDisplayComponent as TextDisplayComponentPayload, TextInput as TextInputPayload, ThumbnailComponent as ThumbnailComponentPayload, + UnfurledMediaItem as UnfurledMediaItemPayload, UserSelectMenu as UserSelectMenuPayload, ) @@ -82,6 +84,7 @@ "TextInput", "Section", "TextDisplay", + "UnfurledMediaItem", "Thumbnail", "MediaGallery", "MediaGalleryItem", @@ -914,6 +917,67 @@ def to_dict(self) -> TextDisplayComponentPayload: } +class UnfurledMediaItem(AssetMixin): + """Represents an unfurled/resolved media item within a component. + + .. versionadded:: 2.11 + + Attributes + ---------- + url: :class:`str` + The URL of this media item. + proxy_url: :class:`str` + The proxied URL of this media item. This is a cached version of + the :attr:`url` in the case of images. + height: Optional[:class:`int`] + The height of this media item, if applicable. + width: Optional[:class:`int`] + The width of this media item, if applicable. + content_type: Optional[:class:`str`] + The `media type `_ of this media item. + attachment_id: Optional[:class:`int`] + The ID of the uploaded attachment. Only present if the media item was + uploaded as an attachment. + """ + + __slots__: Tuple[str, ...] = ( + "url", + "proxy_url", + "height", + "width", + "content_type", + "attachment_id", + ) + + # generally, users should also be able to pass a plain url where applicable instead of + # an UnfurledMediaItem instance; this is largely for internal use + def __init__(self, url: str) -> None: + self.url: str = url + self.proxy_url: Optional[str] = None + self.height: Optional[int] = None + self.width: Optional[int] = None + self.content_type: Optional[str] = None + self.attachment_id: Optional[int] = None + + # FIXME: when deserializing, try to attach _state for AssetMixin as well + @classmethod + def from_dict(cls, data: UnfurledMediaItemPayload) -> Self: + self = cls(data["url"]) + self.proxy_url = data.get("proxy_url") + self.height = _get_as_snowflake(data, "height") + self.width = _get_as_snowflake(data, "width") + self.content_type = data.get("content_type") + self.attachment_id = _get_as_snowflake(data, "attachment_id") + return self + + def to_dict(self) -> UnfurledMediaItemPayload: + # for sending, only `url` is required, and other fields are ignored regardless + return {"url": self.url} + + def __repr__(self) -> str: + return f"" + + class Thumbnail(Component): """Represents a thumbnail from the Discord Bot UI Kit (v2). @@ -945,14 +1009,14 @@ class Thumbnail(Component): def __init__(self, data: ThumbnailComponentPayload) -> None: self.type: Literal[ComponentType.thumbnail] = ComponentType.thumbnail - self.media: Any = data["media"] # TODO: UnfurledMediaItem + self.media: UnfurledMediaItem = UnfurledMediaItem.from_dict(data["media"]) self.description: Optional[str] = data.get("description") self.spoiler: bool = data.get("spoiler", False) def to_dict(self) -> ThumbnailComponentPayload: payload: ThumbnailComponentPayload = { "type": self.type.value, - "media": self.media, + "media": self.media.to_dict(), "spoiler": self.spoiler, } @@ -1005,13 +1069,13 @@ class MediaGalleryItem: # XXX: should this be user-instantiable? def __init__(self, data: MediaGalleryItemPayload) -> None: - self.media: Any = data["media"] # TODO: UnfurledMediaItem + self.media: UnfurledMediaItem = UnfurledMediaItem.from_dict(data["media"]) self.description: Optional[str] = data.get("description") self.spoiler: bool = data.get("spoiler", False) def to_dict(self) -> MediaGalleryItemPayload: payload: MediaGalleryItemPayload = { - "media": self.media, + "media": self.media.to_dict(), "spoiler": self.spoiler, } @@ -1050,13 +1114,13 @@ class FileComponent(Component): def __init__(self, data: FileComponentPayload) -> None: self.type: Literal[ComponentType.file] = ComponentType.file - self.file: Any = data["file"] # TODO: UnfurledMediaItem + self.file: UnfurledMediaItem = UnfurledMediaItem.from_dict(data["file"]) self.spoiler: bool = data.get("spoiler", False) def to_dict(self) -> FileComponentPayload: return { "type": self.type.value, - "file": self.file, + "file": self.file.to_dict(), "spoiler": self.spoiler, } diff --git a/disnake/types/components.py b/disnake/types/components.py index 0943a27750..29a61f03c0 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -2,9 +2,9 @@ from __future__ import annotations -from typing import List, Literal, TypedDict, Union +from typing import List, Literal, Optional, TypedDict, Union -from typing_extensions import NotRequired, TypeAlias +from typing_extensions import NotRequired, Required, TypeAlias from .channel import ChannelType from .emoji import PartialEmoji @@ -162,12 +162,18 @@ class TextInput(_BaseComponent): # components v2 -# NOTE: these are type definitions for *sending*, while *receiving* likely has fewer optional fields -# TODO: this expands to an `EmbedImage`-like structure in responses, with more than just the `url` field -class UnfurledMediaItem(TypedDict): - url: str +class UnfurledMediaItem(TypedDict, total=False): + url: Required[str] # this is the only field required for sending + proxy_url: str + height: Optional[int] + width: Optional[int] + content_type: str + attachment_id: Snowflake + + +# NOTE: these are type definitions for *sending*, while *receiving* likely has fewer optional fields class SectionComponent(_BaseComponent): diff --git a/disnake/ui/_types.py b/disnake/ui/_types.py index a5ee800603..cf4b099353 100644 --- a/disnake/ui/_types.py +++ b/disnake/ui/_types.py @@ -7,6 +7,7 @@ if TYPE_CHECKING: from typing_extensions import TypeAlias + from ..components import UnfurledMediaItem from . import ( ActionRow, Button, @@ -75,3 +76,7 @@ MessageComponents = ComponentInput[ActionRowMessageComponent, MessageTopLevelComponentV2] ModalComponents = ComponentInput[ActionRowModalComponent, NoReturn] + + +# TODO: support `disnake.File` +MediaItemInput = Union[str, "UnfurledMediaItem"] diff --git a/disnake/ui/file.py b/disnake/ui/file.py index 4dedc9f8f6..48a9516695 100644 --- a/disnake/ui/file.py +++ b/disnake/ui/file.py @@ -2,12 +2,15 @@ from __future__ import annotations -from typing import Any, ClassVar, Tuple +from typing import TYPE_CHECKING, ClassVar, Tuple -from ..components import FileComponent +from ..components import FileComponent, UnfurledMediaItem from ..enums import ComponentType from ..utils import MISSING -from .item import UIComponent +from .item import UIComponent, handle_media_item_input + +if TYPE_CHECKING: + from ._types import MediaItemInput __all__ = ("File",) @@ -19,8 +22,9 @@ class File(UIComponent): Parameters ---------- - file: Any - n/a + file: Union[:class:`str`, :class:`.UnfurledMediaItem`] + The file to display. This **only** supports attachment references (i.e. + using the ``attachment://`` syntax), not arbitrary URLs. spoiler: :class:`bool` Whether the file is marked as a spoiler. Defaults to ``False``. """ @@ -34,24 +38,24 @@ class File(UIComponent): def __init__( self, + file: MediaItemInput, *, - file: Any, # XXX: positional? spoiler: bool = False, ) -> None: self._underlying = FileComponent._raw_construct( type=ComponentType.file, - file=file, + file=handle_media_item_input(file), spoiler=spoiler, ) @property - def file(self) -> Any: - """Any: n/a""" + def file(self) -> UnfurledMediaItem: + """:class:`.UnfurledMediaItem`: The file to display.""" return self._underlying.file @file.setter - def file(self, value: Any) -> None: - self._underlying.file = value + def file(self, value: MediaItemInput) -> None: + self._underlying.file = handle_media_item_input(value) @property def spoiler(self) -> bool: diff --git a/disnake/ui/item.py b/disnake/ui/item.py index 9ef86b81b7..4b7102eef8 100644 --- a/disnake/ui/item.py +++ b/disnake/ui/item.py @@ -19,6 +19,8 @@ overload, ) +from ..components import UnfurledMediaItem + __all__ = ( "UIComponent", "WrappedComponent", @@ -36,6 +38,7 @@ from ..enums import ComponentType from ..interactions import MessageInteraction from ..types.components import ActionRowChildComponent as ActionRowChildComponentPayload + from ._types import MediaItemInput from .view import View ItemCallbackType = Callable[[V_co, I, MessageInteraction], Coroutine[Any, Any, Any]] @@ -50,6 +53,13 @@ def ensure_ui_component(obj: UIComponentT, name: str) -> UIComponentT: return obj +# TODO: support `disnake.File` +def handle_media_item_input(value: MediaItemInput) -> UnfurledMediaItem: + if isinstance(value, str): + return UnfurledMediaItem(value) + return value + + class UIComponent(ABC): """Represents the base UI component that all UI components inherit from. diff --git a/disnake/ui/thumbnail.py b/disnake/ui/thumbnail.py index 3cb03835bf..28c665c341 100644 --- a/disnake/ui/thumbnail.py +++ b/disnake/ui/thumbnail.py @@ -2,12 +2,15 @@ from __future__ import annotations -from typing import Any, ClassVar, Optional, Tuple +from typing import TYPE_CHECKING, ClassVar, Optional, Tuple -from ..components import Thumbnail as ThumbnailComponent +from ..components import Thumbnail as ThumbnailComponent, UnfurledMediaItem from ..enums import ComponentType from ..utils import MISSING -from .item import UIComponent +from .item import UIComponent, handle_media_item_input + +if TYPE_CHECKING: + from ._types import MediaItemInput __all__ = ("Thumbnail",) @@ -21,8 +24,8 @@ class Thumbnail(UIComponent): Parameters ---------- - media: Any - n/a + media: Union[:class:`str`, :class:`.UnfurledMediaItem`] + The media item to display. Can be an arbitrary URL or attachment reference. description: Optional[:class:`str`] The thumbnail's description ("alt text"), if any. spoiler: :class:`bool` @@ -39,26 +42,26 @@ class Thumbnail(UIComponent): def __init__( self, - media: Any, + media: MediaItemInput, description: Optional[str] = None, *, spoiler: bool = False, ) -> None: self._underlying = ThumbnailComponent._raw_construct( type=ComponentType.thumbnail, - media=media, + media=handle_media_item_input(media), description=description, spoiler=spoiler, ) @property - def media(self) -> Any: - """Any: n/a""" + def media(self) -> UnfurledMediaItem: + """:class:`.UnfurledMediaItem`: The media item to display.""" return self._underlying.media @media.setter - def media(self, value: Any) -> None: - self._underlying.media = value + def media(self, value: MediaItemInput) -> None: + self._underlying.media = handle_media_item_input(value) @property def description(self) -> Optional[str]: diff --git a/docs/api/components.rst b/docs/api/components.rst index f680adffd3..e90a4e1378 100644 --- a/docs/api/components.rst +++ b/docs/api/components.rst @@ -141,6 +141,16 @@ TextDisplay :members: :inherited-members: + +UnfurledMediaItem +~~~~~~~~~~~~~~~~~ + +.. attributetable:: UnfurledMediaItem + +.. autoclass:: UnfurledMediaItem() + :members: + :inherited-members: + Thumbnail ~~~~~~~~~ From b744af84b1fae523b91be8cf511dd9d13204147e Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 8 Aug 2025 16:07:42 +0200 Subject: [PATCH 051/104] feat: support `Attachment` and `AssetMixin` for component media items --- disnake/components.py | 2 +- disnake/message.py | 3 ++- disnake/ui/_types.py | 5 +++-- disnake/ui/item.py | 13 +++++++++++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 8fa87a4cb0..24cd4d5ebf 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -1265,7 +1265,7 @@ def _walk_internal(component: Component, seen: Set[Component]) -> Iterator[Compo # yields *all* components recursively def _walk_all_components(components: Sequence[Component]) -> Iterator[Component]: - seen = set() + seen: Set[Component] = set() for item in components: yield from _walk_internal(item, seen) diff --git a/disnake/message.py b/disnake/message.py index a6d84af130..558ef3862f 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -49,7 +49,6 @@ from .reaction import Reaction from .sticker import StickerItem from .threads import Thread -from .ui.action_row import components_to_dict from .user import User from .utils import MISSING, _get_as_snowflake, assert_never, deprecated, escape_mentions @@ -218,6 +217,8 @@ async def _edit_handler( payload["components"] = [] if components is not MISSING: + from .ui.action_row import components_to_dict + payload["components"] = [] if components is None else components_to_dict(components) try: diff --git a/disnake/ui/_types.py b/disnake/ui/_types.py index cf4b099353..bb53b48df4 100644 --- a/disnake/ui/_types.py +++ b/disnake/ui/_types.py @@ -7,7 +7,9 @@ if TYPE_CHECKING: from typing_extensions import TypeAlias + from ..asset import AssetMixin from ..components import UnfurledMediaItem + from ..message import Attachment from . import ( ActionRow, Button, @@ -78,5 +80,4 @@ ModalComponents = ComponentInput[ActionRowModalComponent, NoReturn] -# TODO: support `disnake.File` -MediaItemInput = Union[str, "UnfurledMediaItem"] +MediaItemInput = Union[str, "AssetMixin", "Attachment", "UnfurledMediaItem"] diff --git a/disnake/ui/item.py b/disnake/ui/item.py index 4b7102eef8..0cdf83d01a 100644 --- a/disnake/ui/item.py +++ b/disnake/ui/item.py @@ -19,7 +19,10 @@ overload, ) +from ..asset import AssetMixin from ..components import UnfurledMediaItem +from ..message import Attachment +from ..utils import assert_never __all__ = ( "UIComponent", @@ -53,10 +56,16 @@ def ensure_ui_component(obj: UIComponentT, name: str) -> UIComponentT: return obj -# TODO: support `disnake.File` def handle_media_item_input(value: MediaItemInput) -> UnfurledMediaItem: - if isinstance(value, str): + if isinstance(value, UnfurledMediaItem): + return value + elif isinstance(value, str): return UnfurledMediaItem(value) + elif isinstance(value, (AssetMixin, Attachment)): + return UnfurledMediaItem(value.url) + + # TODO: raise proper exception (?) + assert_never(value) return value From a01e35b46706579f83027b11598641a69deee155 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 8 Aug 2025 16:27:38 +0200 Subject: [PATCH 052/104] docs: clarify `attachment://` urls --- disnake/ui/file.py | 2 +- disnake/ui/thumbnail.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/disnake/ui/file.py b/disnake/ui/file.py index 48a9516695..1d2fe310c4 100644 --- a/disnake/ui/file.py +++ b/disnake/ui/file.py @@ -24,7 +24,7 @@ class File(UIComponent): ---------- file: Union[:class:`str`, :class:`.UnfurledMediaItem`] The file to display. This **only** supports attachment references (i.e. - using the ``attachment://`` syntax), not arbitrary URLs. + using the ``attachment://`` syntax), not arbitrary URLs. spoiler: :class:`bool` Whether the file is marked as a spoiler. Defaults to ``False``. """ diff --git a/disnake/ui/thumbnail.py b/disnake/ui/thumbnail.py index 28c665c341..b2a7ec3864 100644 --- a/disnake/ui/thumbnail.py +++ b/disnake/ui/thumbnail.py @@ -25,7 +25,8 @@ class Thumbnail(UIComponent): Parameters ---------- media: Union[:class:`str`, :class:`.UnfurledMediaItem`] - The media item to display. Can be an arbitrary URL or attachment reference. + The media item to display. Can be an arbitrary URL or attachment + reference (``attachment://``). description: Optional[:class:`str`] The thumbnail's description ("alt text"), if any. spoiler: :class:`bool` From 6c4dccc7ab5a8451033cbb722fe083b92a25825a Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 8 Aug 2025 16:56:27 +0200 Subject: [PATCH 053/104] feat: implement `Component.id` --- disnake/components.py | 48 +++++++++++++++++++++++++++++++++---- disnake/types/components.py | 3 +-- disnake/ui/view.py | 1 + 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 24cd4d5ebf..61c01dca8c 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -171,12 +171,21 @@ class Component: ---------- type: :class:`ComponentType` The type of component. + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential + identifiers to the components in the message. + + .. versionadded:: 2.11 """ - __slots__: Tuple[str, ...] = ("type",) + __slots__: Tuple[str, ...] = ("type", "id") __repr_info__: ClassVar[Tuple[str, ...]] type: ComponentType + id: int def __repr__(self) -> str: attrs = " ".join(f"{key}={getattr(self, key)!r}" for key in self.__repr_info__) @@ -219,12 +228,15 @@ class ActionRow(Component, Generic[ActionRowChildComponentT]): def __init__(self, data: ActionRowPayload) -> None: self.type: Literal[ComponentType.action_row] = ComponentType.action_row + self.id = data.get("id", 0) + children = [_component_factory(d) for d in data.get("components", [])] self.children: List[ActionRowChildComponentT] = children # type: ignore def to_dict(self) -> ActionRowPayload: return { "type": self.type.value, + "id": self.id, "components": [child.to_dict() for child in self.children], } @@ -277,6 +289,8 @@ class Button(Component): def __init__(self, data: ButtonComponentPayload) -> None: self.type: Literal[ComponentType.button] = ComponentType.button + self.id = data.get("id", 0) + self.style: ButtonStyle = try_enum(ButtonStyle, data["style"]) self.custom_id: Optional[str] = data.get("custom_id") self.url: Optional[str] = data.get("url") @@ -293,6 +307,7 @@ def __init__(self, data: ButtonComponentPayload) -> None: def to_dict(self) -> ButtonComponentPayload: payload: ButtonComponentPayload = { "type": self.type.value, + "id": self.id, "style": self.style.value, "disabled": self.disabled, } @@ -373,6 +388,7 @@ class BaseSelectMenu(Component): def __init__(self, data: AnySelectMenuPayload) -> None: component_type = try_enum(ComponentType, data["type"]) self.type: SelectMenuType = component_type # type: ignore + self.id = data.get("id", 0) self.custom_id: str = data["custom_id"] self.placeholder: Optional[str] = data.get("placeholder") @@ -386,6 +402,7 @@ def __init__(self, data: AnySelectMenuPayload) -> None: def to_dict(self) -> BaseSelectMenuPayload: payload: BaseSelectMenuPayload = { "type": self.type.value, + "id": self.id, "custom_id": self.custom_id, "min_values": self.min_values, "max_values": self.max_values, @@ -810,11 +827,13 @@ class TextInput(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ def __init__(self, data: TextInputPayload) -> None: - style = data.get("style", TextInputStyle.short.value) - self.type: Literal[ComponentType.text_input] = ComponentType.text_input + self.id = data.get("id", 0) + self.custom_id: str = data["custom_id"] - self.style: TextInputStyle = try_enum(TextInputStyle, style) + self.style: TextInputStyle = try_enum( + TextInputStyle, data.get("style", TextInputStyle.short.value) + ) self.label: Optional[str] = data.get("label") self.placeholder: Optional[str] = data.get("placeholder") self.value: Optional[str] = data.get("value") @@ -825,6 +844,7 @@ def __init__(self, data: TextInputPayload) -> None: def to_dict(self) -> TextInputPayload: payload: TextInputPayload = { "type": self.type.value, + "id": self.id, "style": self.style.value, "label": cast(str, self.label), "custom_id": self.custom_id, @@ -871,6 +891,7 @@ class Section(Component): def __init__(self, data: SectionComponentPayload) -> None: self.type: Literal[ComponentType.section] = ComponentType.section + self.id = data.get("id", 0) accessory = _component_factory(data["accessory"]) self.accessory: SectionAccessoryComponent = accessory # type: ignore @@ -882,6 +903,7 @@ def __init__(self, data: SectionComponentPayload) -> None: def to_dict(self) -> SectionComponentPayload: return { "type": self.type.value, + "id": self.id, "accessory": self.accessory.to_dict(), "components": [child.to_dict() for child in self.components], } @@ -908,11 +930,14 @@ class TextDisplay(Component): def __init__(self, data: TextDisplayComponentPayload) -> None: self.type: Literal[ComponentType.text_display] = ComponentType.text_display + self.id = data.get("id", 0) + self.content: str = data["content"] def to_dict(self) -> TextDisplayComponentPayload: return { "type": self.type.value, + "id": self.id, "content": self.content, } @@ -1009,6 +1034,8 @@ class Thumbnail(Component): def __init__(self, data: ThumbnailComponentPayload) -> None: self.type: Literal[ComponentType.thumbnail] = ComponentType.thumbnail + self.id = data.get("id", 0) + self.media: UnfurledMediaItem = UnfurledMediaItem.from_dict(data["media"]) self.description: Optional[str] = data.get("description") self.spoiler: bool = data.get("spoiler", False) @@ -1016,6 +1043,7 @@ def __init__(self, data: ThumbnailComponentPayload) -> None: def to_dict(self) -> ThumbnailComponentPayload: payload: ThumbnailComponentPayload = { "type": self.type.value, + "id": self.id, "media": self.media.to_dict(), "spoiler": self.spoiler, } @@ -1049,11 +1077,14 @@ class MediaGallery(Component): def __init__(self, data: MediaGalleryComponentPayload) -> None: self.type: Literal[ComponentType.media_gallery] = ComponentType.media_gallery + self.id = data.get("id", 0) + self.items: List[MediaGalleryItem] = [MediaGalleryItem(i) for i in data["items"]] def to_dict(self) -> MediaGalleryComponentPayload: return { "type": self.type.value, + "id": self.id, "items": [i.to_dict() for i in self.items], } @@ -1114,12 +1145,15 @@ class FileComponent(Component): def __init__(self, data: FileComponentPayload) -> None: self.type: Literal[ComponentType.file] = ComponentType.file + self.id = data.get("id", 0) + self.file: UnfurledMediaItem = UnfurledMediaItem.from_dict(data["file"]) self.spoiler: bool = data.get("spoiler", False) def to_dict(self) -> FileComponentPayload: return { "type": self.type.value, + "id": self.id, "file": self.file.to_dict(), "spoiler": self.spoiler, } @@ -1151,6 +1185,8 @@ class Separator(Component): def __init__(self, data: SeparatorComponentPayload) -> None: self.type: Literal[ComponentType.separator] = ComponentType.separator + self.id = data.get("id", 0) + self.divider: bool = data.get("divider", True) # TODO: `size` instead of `spacing`? self.spacing: SeparatorSpacingSize = try_enum(SeparatorSpacingSize, data.get("spacing", 1)) @@ -1158,6 +1194,7 @@ def __init__(self, data: SeparatorComponentPayload) -> None: def to_dict(self) -> SeparatorComponentPayload: return { "type": self.type.value, + "id": self.id, "divider": self.divider, "spacing": self.spacing.value, } @@ -1196,6 +1233,8 @@ class Container(Component): def __init__(self, data: ContainerComponentPayload) -> None: self.type: Literal[ComponentType.container] = ComponentType.container + self.id = data.get("id", 0) + self._accent_colour: Optional[int] = data.get("accent_color") self.spoiler: bool = data.get("spoiler", False) @@ -1205,6 +1244,7 @@ def __init__(self, data: ContainerComponentPayload) -> None: def to_dict(self) -> ContainerComponentPayload: payload: ContainerComponentPayload = { "type": self.type.value, + "id": self.id, "spoiler": self.spoiler, "components": [child.to_dict() for child in self.components], } diff --git a/disnake/types/components.py b/disnake/types/components.py index 29a61f03c0..bacc81d910 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -56,8 +56,7 @@ class _BaseComponent(TypedDict): # type: ComponentType # FIXME: current version of pyright only supports PEP 705 experimentally, this can be re-enabled in 1.1.353+ - # TODO: always present in responses - id: NotRequired[int] # NOTE: not implemented (yet?) + id: int # note: technically optional when sending, we just default to 0 for simplicity, which is equivalent (https://discord.com/developers/docs/components/reference#anatomy-of-a-component) class ActionRow(_BaseComponent): diff --git a/disnake/ui/view.py b/disnake/ui/view.py index 2242236e5a..63c3ac4300 100644 --- a/disnake/ui/view.py +++ b/disnake/ui/view.py @@ -193,6 +193,7 @@ def key(item: Item) -> int: components.append( { "type": 1, + "id": 0, "components": children, } ) From 137db4c4f55a62af4ba584ffe7d16f401749462f Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 9 Aug 2025 12:08:02 +0200 Subject: [PATCH 054/104] feat: implement `.id` in ui component types --- disnake/ui/action_row.py | 43 ++++++++++++++++++++------- disnake/ui/button.py | 11 +++++++ disnake/ui/container.py | 20 ++++++++++++- disnake/ui/file.py | 6 ++++ disnake/ui/item.py | 14 +++++++++ disnake/ui/media_gallery.py | 7 ++++- disnake/ui/section.py | 20 ++++++++++++- disnake/ui/select/base.py | 4 ++- disnake/ui/select/channel.py | 11 +++++++ disnake/ui/select/mentionable.py | 11 +++++++ disnake/ui/select/role.py | 11 +++++++ disnake/ui/select/string.py | 11 +++++++ disnake/ui/select/user.py | 11 +++++++ disnake/ui/separator.py | 6 ++++ disnake/ui/text_display.py | 7 ++++- disnake/ui/text_input.py | 8 +++++ disnake/ui/thumbnail.py | 6 ++++ disnake/utils.py | 11 +++---- tests/ui/test_components.py | 50 ++++++++++++++++++++++++++++++++ 19 files changed, 248 insertions(+), 20 deletions(-) create mode 100644 tests/ui/test_components.py diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index 8a6898a76d..fadfd616af 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -31,7 +31,7 @@ UserSelectMenu as UserSelectComponent, ) from ..enums import ButtonStyle, ChannelType, ComponentType, TextInputStyle -from ..utils import MISSING, SequenceProxy, assert_never +from ..utils import MISSING, SequenceProxy, assert_never, copy_doc from ._types import ( ActionRowChildT, ActionRowMessageComponent, @@ -157,6 +157,12 @@ class ActionRow(UIComponent, Generic[ActionRowChildT]): .. versionchanged:: 2.6 Components can now be either valid in the context of a message, or in the context of a modal. Combining components from both contexts is not supported. + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 """ __repr_attributes__: ClassVar[Tuple[str, ...]] = ("_children",) @@ -164,7 +170,7 @@ class ActionRow(UIComponent, Generic[ActionRowChildT]): # When unspecified and called empty, default to an ActionRow that takes any kind of component. @overload - def __init__(self: ActionRow[WrappedComponent]) -> None: ... + def __init__(self: ActionRow[WrappedComponent], *, id: int = 0) -> None: ... # Explicit definitions are needed to make # "ActionRow(StringSelect(), TextInput())" and @@ -173,19 +179,24 @@ def __init__(self: ActionRow[WrappedComponent]) -> None: ... @overload def __init__( - self: ActionRow[ActionRowMessageComponent], *components: ActionRowMessageComponent + self: ActionRow[ActionRowMessageComponent], + *components: ActionRowMessageComponent, + id: int = 0, ) -> None: ... @overload def __init__( - self: ActionRow[ActionRowModalComponent], *components: ActionRowModalComponent + self: ActionRow[ActionRowModalComponent], + *components: ActionRowModalComponent, + id: int = 0, ) -> None: ... @overload - def __init__(self, *components: ActionRowChildT) -> None: ... + def __init__(self, *components: ActionRowChildT, id: int = 0) -> None: ... # n.b. this should be `*components: ActionRowChildT`, but pyright does not like it - def __init__(self, *components: WrappedComponent) -> None: + def __init__(self, *components: WrappedComponent, id: int = 0) -> None: + self._id: int = id self._children: List[ActionRowChildT] = [] for component in components: @@ -198,6 +209,17 @@ def __init__(self, *components: WrappedComponent) -> None: def __len__(self) -> int: return len(self._children) + # these are reimplemented here to store the value in a separate attribute, + # since `ActionRow` lazily constructs `_underlying`, unlike most components + @property + @copy_doc(UIComponent.id) + def id(self) -> int: + return self._id + + @id.setter + def id(self, value: int) -> None: + self._id = value + @property def children(self) -> Sequence[ActionRowChildT]: """Sequence[:class:`WrappedComponent`]: @@ -739,6 +761,7 @@ def pop(self, index: int) -> ActionRowChildT: def _underlying(self) -> ActionRowComponent[ActionRowChildComponent]: return ActionRowComponent._raw_construct( type=ComponentType.action_row, + id=self._id, children=[comp._underlying for comp in self._children], ) @@ -764,7 +787,7 @@ def __iter__(self) -> Iterator[ActionRowChildT]: return iter(self._children) @classmethod - def with_modal_components(cls) -> ActionRow[ActionRowModalComponent]: + def with_modal_components(cls, *, id: int = 0) -> ActionRow[ActionRowModalComponent]: """Create an empty action row meant to store components compatible with :class:`disnake.ui.Modal`. Saves the need to import type specifiers to typehint empty action rows. @@ -776,10 +799,10 @@ def with_modal_components(cls) -> ActionRow[ActionRowModalComponent]: :class:`ActionRow`: The newly created empty action row, intended for modal components. """ - return ActionRow[ActionRowModalComponent]() + return ActionRow[ActionRowModalComponent](id=id) @classmethod - def with_message_components(cls) -> ActionRow[ActionRowMessageComponent]: + def with_message_components(cls, *, id: int = 0) -> ActionRow[ActionRowMessageComponent]: """Create an empty action row meant to store components compatible with :class:`disnake.Message`. Saves the need to import type specifiers to typehint empty action rows. @@ -791,7 +814,7 @@ def with_message_components(cls) -> ActionRow[ActionRowMessageComponent]: :class:`ActionRow`: The newly created empty action row, intended for message components. """ - return ActionRow[ActionRowMessageComponent]() + return ActionRow[ActionRowMessageComponent](id=id) @classmethod def rows_from_message( diff --git a/disnake/ui/button.py b/disnake/ui/button.py index e28a6eff1a..5465e75bcf 100644 --- a/disnake/ui/button.py +++ b/disnake/ui/button.py @@ -67,6 +67,12 @@ class Button(Item[V_co]): The ID of a purchasable SKU, for premium buttons. Premium buttons additionally cannot have a ``label``, ``url``, or ``emoji``. + .. versionadded:: 2.11 + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + .. versionadded:: 2.11 row: Optional[:class:`int`] The relative row this button belongs to. A Discord component can only have 5 @@ -99,6 +105,7 @@ def __init__( url: Optional[str] = None, emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, sku_id: Optional[int] = None, + id: int = 0, row: Optional[int] = None, ) -> None: ... @@ -113,6 +120,7 @@ def __init__( url: Optional[str] = None, emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, sku_id: Optional[int] = None, + id: int = 0, row: Optional[int] = None, ) -> None: ... @@ -126,6 +134,7 @@ def __init__( url: Optional[str] = None, emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, sku_id: Optional[int] = None, + id: int = 0, row: Optional[int] = None, ) -> None: super().__init__() @@ -155,6 +164,7 @@ def __init__( self._underlying = ButtonComponent._raw_construct( type=ComponentType.button, + id=id, custom_id=custom_id, url=url, disabled=disabled, @@ -265,6 +275,7 @@ def from_component(cls, button: ButtonComponent) -> Self: url=button.url, emoji=button.emoji, sku_id=button.sku_id, + id=button.id, row=None, ) diff --git a/disnake/ui/container.py b/disnake/ui/container.py index 2126e515d2..fb5cc5bb15 100644 --- a/disnake/ui/container.py +++ b/disnake/ui/container.py @@ -7,7 +7,7 @@ from ..colour import Colour from ..components import Container as ContainerComponent from ..enums import ComponentType -from ..utils import SequenceProxy +from ..utils import SequenceProxy, copy_doc from .item import UIComponent, ensure_ui_component if TYPE_CHECKING: @@ -45,6 +45,10 @@ class Container(UIComponent): The accent colour of the container. spoiler: :class:`bool` Whether the container is marked as a spoiler. Defaults to ``False``. + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. Attributes ---------- @@ -66,7 +70,9 @@ def __init__( *components: ContainerChildUIComponent, accent_colour: Optional[Colour] = None, spoiler: bool = False, + id: int = 0, ) -> None: + self._id: int = id self._components: List[ContainerChildUIComponent] = [ ensure_ui_component(c, "components") for c in components ] @@ -74,6 +80,17 @@ def __init__( self.accent_colour: Optional[Colour] = accent_colour self.spoiler: bool = spoiler + # these are reimplemented here to store the value in a separate attribute, + # since `Container` lazily constructs `_underlying`, unlike most components + @property + @copy_doc(UIComponent.id) + def id(self) -> int: + return self._id + + @id.setter + def id(self, value: int) -> None: + self._id = value + # TODO: consider moving runtime checks from constructor into property setters, also making these fields writable @property def components(self) -> Sequence[ContainerChildUIComponent]: @@ -84,6 +101,7 @@ def components(self) -> Sequence[ContainerChildUIComponent]: def _underlying(self) -> ContainerComponent: return ContainerComponent._raw_construct( type=ComponentType.container, + id=self._id, components=[comp._underlying for comp in self._components], _accent_colour=self.accent_colour.value if self.accent_colour is not None else None, spoiler=self.spoiler, diff --git a/disnake/ui/file.py b/disnake/ui/file.py index 1d2fe310c4..d940290625 100644 --- a/disnake/ui/file.py +++ b/disnake/ui/file.py @@ -27,6 +27,10 @@ class File(UIComponent): using the ``attachment://`` syntax), not arbitrary URLs. spoiler: :class:`bool` Whether the file is marked as a spoiler. Defaults to ``False``. + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. """ __repr_attributes__: ClassVar[Tuple[str, ...]] = ( @@ -41,9 +45,11 @@ def __init__( file: MediaItemInput, *, spoiler: bool = False, + id: int = 0, ) -> None: self._underlying = FileComponent._raw_construct( type=ComponentType.file, + id=id, file=handle_media_item_input(file), spoiler=spoiler, ) diff --git a/disnake/ui/item.py b/disnake/ui/item.py index 0cdf83d01a..0e7a810757 100644 --- a/disnake/ui/item.py +++ b/disnake/ui/item.py @@ -105,6 +105,20 @@ def __repr__(self) -> str: def type(self) -> ComponentType: return self._underlying.type + @property + def id(self) -> int: + """:class:`int`: The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + + .. versionadded:: 2.11 + """ + return self._underlying.id + + @id.setter + def id(self, value: int) -> None: + self._underlying.id = value + def to_component_dict(self) -> Dict[str, Any]: return self._underlying.to_dict() diff --git a/disnake/ui/media_gallery.py b/disnake/ui/media_gallery.py index 9d919b1e0b..c0fc909e6a 100644 --- a/disnake/ui/media_gallery.py +++ b/disnake/ui/media_gallery.py @@ -21,6 +21,10 @@ class MediaGallery(UIComponent): ---------- *items: :class:`.MediaGalleryItem` The list of images in this gallery. + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. """ __repr_attributes__: ClassVar[Tuple[str, ...]] = ("items",) @@ -28,9 +32,10 @@ class MediaGallery(UIComponent): _underlying: MediaGalleryComponent = MISSING # FIXME: MediaGalleryItem currently isn't user-instantiable - def __init__(self, *items: MediaGalleryItem) -> None: + def __init__(self, *items: MediaGalleryItem, id: int = 0) -> None: self._underlying = MediaGalleryComponent._raw_construct( type=ComponentType.media_gallery, + id=id, items=list(items), ) diff --git a/disnake/ui/section.py b/disnake/ui/section.py index ef1fda9c72..c3008d84b8 100644 --- a/disnake/ui/section.py +++ b/disnake/ui/section.py @@ -6,7 +6,7 @@ from ..components import Section as SectionComponent from ..enums import ComponentType -from ..utils import SequenceProxy +from ..utils import SequenceProxy, copy_doc from .item import UIComponent, ensure_ui_component if TYPE_CHECKING: @@ -28,6 +28,10 @@ class Section(UIComponent): The list of text items in this section. accessory: Union[:class:`~.ui.Thumbnail`, :class:`~.ui.Button`] The accessory component displayed next to the section text. + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. """ __repr_attributes__: ClassVar[Tuple[str, ...]] = ( @@ -40,12 +44,25 @@ def __init__( self, *components: TextDisplay, accessory: Union[Thumbnail, Button[Any]], + id: int = 0, ) -> None: + self._id: int = id self._components: List[TextDisplay] = [ ensure_ui_component(c, "components") for c in components ] self._accessory: Union[Thumbnail, Button[Any]] = ensure_ui_component(accessory, "accessory") + # these are reimplemented here to store the value in a separate attribute, + # since `Section` lazily constructs `_underlying`, unlike most components + @property + @copy_doc(UIComponent.id) + def id(self) -> int: + return self._id + + @id.setter + def id(self, value: int) -> None: + self._id = value + # TODO: consider moving runtime checks from constructor into property setters, also making these fields writable @property def components(self) -> Sequence[TextDisplay]: @@ -61,6 +78,7 @@ def accessory(self) -> Union[Thumbnail, Button[Any]]: def _underlying(self) -> SectionComponent: return SectionComponent._raw_construct( type=ComponentType.section, + id=self._id, components=[comp._underlying for comp in self._components], accessory=self._accessory._underlying, ) diff --git a/disnake/ui/select/base.py b/disnake/ui/select/base.py index 09a7e7f1b5..d1db2efb4e 100644 --- a/disnake/ui/select/base.py +++ b/disnake/ui/select/base.py @@ -90,6 +90,7 @@ def __init__( max_values: int, disabled: bool, default_values: Optional[Sequence[SelectDefaultValueInputType[SelectValueT]]], + id: int, row: Optional[int], ) -> None: super().__init__() @@ -97,8 +98,9 @@ def __init__( self._provided_custom_id = custom_id is not MISSING custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id self._underlying = underlying_type._raw_construct( - custom_id=custom_id, type=component_type, + id=id, + custom_id=custom_id, placeholder=placeholder, min_values=min_values, max_values=max_values, diff --git a/disnake/ui/select/channel.py b/disnake/ui/select/channel.py index e0f30888dd..a80f5fba4b 100644 --- a/disnake/ui/select/channel.py +++ b/disnake/ui/select/channel.py @@ -71,6 +71,12 @@ class ChannelSelect(BaseSelect[ChannelSelectMenu, "AnyChannel", V_co]): If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 row: Optional[:class:`int`] The relative row this select menu belongs to. A Discord component can only have 5 rows. By default, items are arranged automatically into those 5 rows. If you'd @@ -112,6 +118,7 @@ def __init__( disabled: bool = False, channel_types: Optional[List[ChannelType]] = None, default_values: Optional[Sequence[SelectDefaultValueInputType[AnyChannel]]] = None, + id: int = 0, row: Optional[int] = None, ) -> None: ... @@ -126,6 +133,7 @@ def __init__( disabled: bool = False, channel_types: Optional[List[ChannelType]] = None, default_values: Optional[Sequence[SelectDefaultValueInputType[AnyChannel]]] = None, + id: int = 0, row: Optional[int] = None, ) -> None: ... @@ -139,6 +147,7 @@ def __init__( disabled: bool = False, channel_types: Optional[List[ChannelType]] = None, default_values: Optional[Sequence[SelectDefaultValueInputType[AnyChannel]]] = None, + id: int = 0, row: Optional[int] = None, ) -> None: super().__init__( @@ -150,6 +159,7 @@ def __init__( max_values=max_values, disabled=disabled, default_values=default_values, + id=id, row=row, ) self._underlying.channel_types = channel_types or None @@ -164,6 +174,7 @@ def from_component(cls, component: ChannelSelectMenu) -> Self: disabled=component.disabled, channel_types=component.channel_types, default_values=component.default_values, + id=component.id, row=None, ) diff --git a/disnake/ui/select/mentionable.py b/disnake/ui/select/mentionable.py index 24f2237472..69b79e6c12 100644 --- a/disnake/ui/select/mentionable.py +++ b/disnake/ui/select/mentionable.py @@ -69,6 +69,12 @@ class MentionableSelect(BaseSelect[MentionableSelectMenu, "Union[User, Member, R Note that unlike other select menu types, this does not support :class:`.Object`\\s due to ambiguities. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 row: Optional[:class:`int`] The relative row this select menu belongs to. A Discord component can only have 5 rows. By default, items are arranged automatically into those 5 rows. If you'd @@ -101,6 +107,7 @@ def __init__( default_values: Optional[ Sequence[SelectDefaultValueMultiInputType[Union[User, Member, Role]]] ] = None, + id: int = 0, row: Optional[int] = None, ) -> None: ... @@ -116,6 +123,7 @@ def __init__( default_values: Optional[ Sequence[SelectDefaultValueMultiInputType[Union[User, Member, Role]]] ] = None, + id: int = 0, row: Optional[int] = None, ) -> None: ... @@ -130,6 +138,7 @@ def __init__( default_values: Optional[ Sequence[SelectDefaultValueMultiInputType[Union[User, Member, Role]]] ] = None, + id: int = 0, row: Optional[int] = None, ) -> None: super().__init__( @@ -141,6 +150,7 @@ def __init__( max_values=max_values, disabled=disabled, default_values=default_values, + id=id, row=row, ) @@ -153,6 +163,7 @@ def from_component(cls, component: MentionableSelectMenu) -> Self: max_values=component.max_values, disabled=component.disabled, default_values=component.default_values, + id=component.id, row=None, ) diff --git a/disnake/ui/select/role.py b/disnake/ui/select/role.py index ed81870bcc..812bc72bb7 100644 --- a/disnake/ui/select/role.py +++ b/disnake/ui/select/role.py @@ -65,6 +65,12 @@ class RoleSelect(BaseSelect[RoleSelectMenu, "Role", V_co]): If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 row: Optional[:class:`int`] The relative row this select menu belongs to. A Discord component can only have 5 rows. By default, items are arranged automatically into those 5 rows. If you'd @@ -94,6 +100,7 @@ def __init__( max_values: int = 1, disabled: bool = False, default_values: Optional[Sequence[SelectDefaultValueInputType[Role]]] = None, + id: int = 0, row: Optional[int] = None, ) -> None: ... @@ -107,6 +114,7 @@ def __init__( max_values: int = 1, disabled: bool = False, default_values: Optional[Sequence[SelectDefaultValueInputType[Role]]] = None, + id: int = 0, row: Optional[int] = None, ) -> None: ... @@ -119,6 +127,7 @@ def __init__( max_values: int = 1, disabled: bool = False, default_values: Optional[Sequence[SelectDefaultValueInputType[Role]]] = None, + id: int = 0, row: Optional[int] = None, ) -> None: super().__init__( @@ -130,6 +139,7 @@ def __init__( max_values=max_values, disabled=disabled, default_values=default_values, + id=id, row=row, ) @@ -142,6 +152,7 @@ def from_component(cls, component: RoleSelectMenu) -> Self: max_values=component.max_values, disabled=component.disabled, default_values=component.default_values, + id=component.id, row=None, ) diff --git a/disnake/ui/select/string.py b/disnake/ui/select/string.py index 4141e07382..73c41aee23 100644 --- a/disnake/ui/select/string.py +++ b/disnake/ui/select/string.py @@ -86,6 +86,12 @@ class StringSelect(BaseSelect[StringSelectMenu, str, V_co]): disabled: :class:`bool` Whether the select is disabled. + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 row: Optional[:class:`int`] The relative row this select menu belongs to. A Discord component can only have 5 rows. By default, items are arranged automatically into those 5 rows. If you'd @@ -116,6 +122,7 @@ def __init__( max_values: int = 1, options: SelectOptionInput = ..., disabled: bool = False, + id: int = 0, row: Optional[int] = None, ) -> None: ... @@ -129,6 +136,7 @@ def __init__( max_values: int = 1, options: SelectOptionInput = ..., disabled: bool = False, + id: int = 0, row: Optional[int] = None, ) -> None: ... @@ -141,6 +149,7 @@ def __init__( max_values: int = 1, options: SelectOptionInput = MISSING, disabled: bool = False, + id: int = 0, row: Optional[int] = None, ) -> None: super().__init__( @@ -152,6 +161,7 @@ def __init__( max_values=max_values, disabled=disabled, default_values=None, + id=id, row=row, ) self._underlying.options = [] if options is MISSING else _parse_select_options(options) @@ -165,6 +175,7 @@ def from_component(cls, component: StringSelectMenu) -> Self: max_values=component.max_values, options=component.options, disabled=component.disabled, + id=component.id, row=None, ) diff --git a/disnake/ui/select/user.py b/disnake/ui/select/user.py index 1e69a91739..6cb19485ac 100644 --- a/disnake/ui/select/user.py +++ b/disnake/ui/select/user.py @@ -67,6 +67,12 @@ class UserSelect(BaseSelect[UserSelectMenu, "Union[User, Member]", V_co]): If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 row: Optional[:class:`int`] The relative row this select menu belongs to. A Discord component can only have 5 rows. By default, items are arranged automatically into those 5 rows. If you'd @@ -96,6 +102,7 @@ def __init__( max_values: int = 1, disabled: bool = False, default_values: Optional[Sequence[SelectDefaultValueInputType[Union[User, Member]]]] = None, + id: int = 0, row: Optional[int] = None, ) -> None: ... @@ -109,6 +116,7 @@ def __init__( max_values: int = 1, disabled: bool = False, default_values: Optional[Sequence[SelectDefaultValueInputType[Union[User, Member]]]] = None, + id: int = 0, row: Optional[int] = None, ) -> None: ... @@ -121,6 +129,7 @@ def __init__( max_values: int = 1, disabled: bool = False, default_values: Optional[Sequence[SelectDefaultValueInputType[Union[User, Member]]]] = None, + id: int = 0, row: Optional[int] = None, ) -> None: super().__init__( @@ -132,6 +141,7 @@ def __init__( max_values=max_values, disabled=disabled, default_values=default_values, + id=id, row=row, ) @@ -144,6 +154,7 @@ def from_component(cls, component: UserSelectMenu) -> Self: max_values=component.max_values, disabled=component.disabled, default_values=component.default_values, + id=component.id, row=None, ) diff --git a/disnake/ui/separator.py b/disnake/ui/separator.py index 79f5d05e66..8bcb86e5d1 100644 --- a/disnake/ui/separator.py +++ b/disnake/ui/separator.py @@ -25,6 +25,10 @@ class Separator(UIComponent): spacing: :class:`.SeparatorSpacingSize` The size of the separator. Defaults to :attr:`~.SeparatorSpacingSize.small`. + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. """ __repr_attributes__: ClassVar[Tuple[str, ...]] = ( @@ -39,9 +43,11 @@ def __init__( *, divider: bool = True, spacing: SeparatorSpacingSize = SeparatorSpacingSize.small, + id: int = 0, ) -> None: self._underlying = SeparatorComponent._raw_construct( type=ComponentType.separator, + id=id, divider=divider, spacing=spacing, ) diff --git a/disnake/ui/text_display.py b/disnake/ui/text_display.py index 8b165475e0..f26c91f5b8 100644 --- a/disnake/ui/text_display.py +++ b/disnake/ui/text_display.py @@ -22,15 +22,20 @@ class TextDisplay(UIComponent): ---------- content: :class:`str` The text displayed by this component. + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. """ __repr_attributes__: ClassVar[Tuple[str, ...]] = ("content",) # We have to set this to MISSING in order to overwrite the abstract property from UIComponent _underlying: TextDisplayComponent = MISSING - def __init__(self, content: str) -> None: + def __init__(self, content: str, *, id: int = 0) -> None: self._underlying = TextDisplayComponent._raw_construct( type=ComponentType.text_display, + id=id, content=content, ) diff --git a/disnake/ui/text_input.py b/disnake/ui/text_input.py index 066dc0e04c..4b7a41daf9 100644 --- a/disnake/ui/text_input.py +++ b/disnake/ui/text_input.py @@ -37,6 +37,12 @@ class TextInput(WrappedComponent): The minimum length of the text input. max_length: Optional[:class:`int`] The maximum length of the text input. + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 """ __repr_attributes__: ClassVar[Tuple[str, ...]] = ( @@ -63,9 +69,11 @@ def __init__( required: bool = True, min_length: Optional[int] = None, max_length: Optional[int] = None, + id: int = 0, ) -> None: self._underlying = TextInputComponent._raw_construct( type=ComponentType.text_input, + id=id, style=style, label=label, custom_id=custom_id, diff --git a/disnake/ui/thumbnail.py b/disnake/ui/thumbnail.py index b2a7ec3864..a2d0402e94 100644 --- a/disnake/ui/thumbnail.py +++ b/disnake/ui/thumbnail.py @@ -31,6 +31,10 @@ class Thumbnail(UIComponent): The thumbnail's description ("alt text"), if any. spoiler: :class:`bool` Whether the thumbnail is marked as a spoiler. Defaults to ``False``. + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. """ __repr_attributes__: ClassVar[Tuple[str, ...]] = ( @@ -47,9 +51,11 @@ def __init__( description: Optional[str] = None, *, spoiler: bool = False, + id: int = 0, ) -> None: self._underlying = ThumbnailComponent._raw_construct( type=ComponentType.thumbnail, + id=id, media=handle_media_item_input(media), description=description, spoiler=spoiler, diff --git a/disnake/utils.py b/disnake/utils.py index d4d61a9579..0100625b84 100644 --- a/disnake/utils.py +++ b/disnake/utils.py @@ -240,11 +240,12 @@ def isoformat_utc(dt: Optional[datetime.datetime]) -> Optional[str]: return None -def copy_doc(original: Callable) -> Callable[[T], T]: - def decorator(overriden: T) -> T: - overriden.__doc__ = original.__doc__ - overriden.__signature__ = _signature(original) # type: ignore - return overriden +def copy_doc(original: Union[Callable, property]) -> Callable[[T], T]: + def decorator(overridden: T) -> T: + overridden.__doc__ = original.__doc__ + if callable(original): + overridden.__signature__ = _signature(original) # type: ignore + return overridden return decorator diff --git a/tests/ui/test_components.py b/tests/ui/test_components.py new file mode 100644 index 0000000000..d57c86305f --- /dev/null +++ b/tests/ui/test_components.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: MIT + +import inspect +from typing import List, Type + +import pytest + +from disnake import ui + +all_ui_component_types: List[Type[ui.UIComponent]] = [ + c + for c in ui.__dict__.values() + if isinstance(c, type) and issubclass(c, ui.UIComponent) and not inspect.isabstract(c) +] + +all_ui_component_objects: List[ui.UIComponent] = [ + ui.ActionRow(), + ui.Button(), + ui.ChannelSelect(), + ui.MentionableSelect(), + ui.RoleSelect(), + ui.StringSelect(), + ui.UserSelect(), + ui.TextInput(label="", custom_id=""), + ui.Section(accessory=ui.Button()), + ui.TextDisplay(""), + ui.Thumbnail(""), + ui.MediaGallery(), + ui.File(""), + ui.Separator(), + ui.Container(), +] + +_missing = set(all_ui_component_types) ^ set(map(type, all_ui_component_objects)) +assert not _missing, f"missing component objects: {_missing}" + + +@pytest.mark.parametrize( + "obj", + all_ui_component_objects, + ids=[type(o).__name__ for o in all_ui_component_objects], +) +def test_id_property(obj: ui.UIComponent) -> None: + assert obj.id == 0 + obj.id = 1234 + assert obj.id == 1234 + + if isinstance(obj, ui.Item): + obj2 = type(obj).from_component(obj._underlying) + assert obj2.id == 1234 From 97b1b6403404b65ab76d369359f0f3c8f83427ab Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 9 Aug 2025 12:24:13 +0200 Subject: [PATCH 055/104] fix: add missing `id` param to decorators + `ActionRow.add_*` --- disnake/ui/action_row.py | 56 ++++++++++++++++++++++++++++++++ disnake/ui/button.py | 7 ++++ disnake/ui/select/channel.py | 19 +++++++---- disnake/ui/select/mentionable.py | 19 +++++++---- disnake/ui/select/role.py | 19 +++++++---- disnake/ui/select/string.py | 19 +++++++---- disnake/ui/select/user.py | 19 +++++++---- 7 files changed, 128 insertions(+), 30 deletions(-) diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index fadfd616af..59b3b7d642 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -291,6 +291,7 @@ def add_button( url: Optional[str] = None, emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, sku_id: Optional[int] = None, + id: int = 0, ) -> ButtonCompatibleActionRowT: """Add a button to the action row. Can only be used if the action row holds message components. @@ -326,6 +327,12 @@ def add_button( The ID of a purchasable SKU, for premium buttons. Premium buttons additionally cannot have a ``label``, ``url``, or ``emoji``. + .. versionadded:: 2.11 + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + .. versionadded:: 2.11 Raises @@ -336,6 +343,7 @@ def add_button( self.insert_item( len(self) if index is None else index, Button( + id=id, style=style, label=label, disabled=disabled, @@ -356,6 +364,7 @@ def add_string_select( max_values: int = 1, options: SelectOptionInput = MISSING, disabled: bool = False, + id: int = 0, ) -> SelectCompatibleActionRowT: """Add a string select menu to the action row. Can only be used if the action row holds message components. @@ -387,6 +396,12 @@ def add_string_select( as a list of labels, and a dict will be treated as a mapping of labels to values. disabled: :class:`bool` Whether the select is disabled or not. + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 Raises ------ @@ -395,6 +410,7 @@ def add_string_select( """ self.append_item( StringSelect( + id=id, custom_id=custom_id, placeholder=placeholder, min_values=min_values, @@ -416,6 +432,7 @@ def add_user_select( max_values: int = 1, disabled: bool = False, default_values: Optional[Sequence[SelectDefaultValueInputType[Union[User, Member]]]] = None, + id: int = 0, ) -> SelectCompatibleActionRowT: """Add a user select menu to the action row. Can only be used if the action row holds message components. @@ -447,6 +464,12 @@ def add_user_select( If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 Raises ------ @@ -455,6 +478,7 @@ def add_user_select( """ self.append_item( UserSelect( + id=id, custom_id=custom_id, placeholder=placeholder, min_values=min_values, @@ -474,6 +498,7 @@ def add_role_select( max_values: int = 1, disabled: bool = False, default_values: Optional[Sequence[SelectDefaultValueInputType[Role]]] = None, + id: int = 0, ) -> SelectCompatibleActionRowT: """Add a role select menu to the action row. Can only be used if the action row holds message components. @@ -505,6 +530,12 @@ def add_role_select( If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 Raises ------ @@ -513,6 +544,7 @@ def add_role_select( """ self.append_item( RoleSelect( + id=id, custom_id=custom_id, placeholder=placeholder, min_values=min_values, @@ -534,6 +566,7 @@ def add_mentionable_select( default_values: Optional[ Sequence[SelectDefaultValueMultiInputType[Union[User, Member, Role]]] ] = None, + id: int = 0, ) -> SelectCompatibleActionRowT: """Add a mentionable (user/member/role) select menu to the action row. Can only be used if the action row holds message components. @@ -567,6 +600,12 @@ def add_mentionable_select( Note that unlike other select menu types, this does not support :class:`.Object`\\s due to ambiguities. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 Raises ------ @@ -575,6 +614,7 @@ def add_mentionable_select( """ self.append_item( MentionableSelect( + id=id, custom_id=custom_id, placeholder=placeholder, min_values=min_values, @@ -595,6 +635,7 @@ def add_channel_select( disabled: bool = False, channel_types: Optional[List[ChannelType]] = None, default_values: Optional[Sequence[SelectDefaultValueInputType[AnyChannel]]] = None, + id: int = 0, ) -> SelectCompatibleActionRowT: """Add a channel select menu to the action row. Can only be used if the action row holds message components. @@ -629,6 +670,12 @@ def add_channel_select( If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 Raises ------ @@ -637,6 +684,7 @@ def add_channel_select( """ self.append_item( ChannelSelect( + id=id, custom_id=custom_id, placeholder=placeholder, min_values=min_values, @@ -659,6 +707,7 @@ def add_text_input( required: bool = True, min_length: Optional[int] = None, max_length: Optional[int] = None, + id: int = 0, ) -> TextInputCompatibleActionRowT: """Add a text input to the action row. Can only be used if the action row holds modal components. @@ -688,6 +737,12 @@ def add_text_input( The minimum length of the text input. max_length: Optional[:class:`int`] The maximum length of the text input. + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 Raises ------ @@ -696,6 +751,7 @@ def add_text_input( """ self.append_item( TextInput( + id=id, label=label, custom_id=custom_id, style=style, diff --git a/disnake/ui/button.py b/disnake/ui/button.py index 5465e75bcf..d8df90ab6e 100644 --- a/disnake/ui/button.py +++ b/disnake/ui/button.py @@ -301,6 +301,7 @@ def button( disabled: bool = False, style: ButtonStyle = ButtonStyle.secondary, emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, + id: int = 0, row: Optional[int] = None, ) -> Callable[[ItemCallbackType[V_co, Button[V_co]]], DecoratedItem[Button[V_co]]]: ... @@ -347,6 +348,12 @@ def button( emoji: Optional[Union[:class:`str`, :class:`.Emoji`, :class:`.PartialEmoji`]] The emoji of the button. This can be in string form or a :class:`.PartialEmoji` or a full :class:`.Emoji`. + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 row: Optional[:class:`int`] The relative row this button belongs to. A Discord component can only have 5 rows. By default, items are arranged automatically into those 5 rows. If you'd diff --git a/disnake/ui/select/channel.py b/disnake/ui/select/channel.py index a80f5fba4b..074717bfe6 100644 --- a/disnake/ui/select/channel.py +++ b/disnake/ui/select/channel.py @@ -207,6 +207,7 @@ def channel_select( disabled: bool = False, channel_types: Optional[List[ChannelType]] = None, default_values: Optional[Sequence[SelectDefaultValueInputType[AnyChannel]]] = None, + id: int = 0, row: Optional[int] = None, ) -> Callable[ [ItemCallbackType[V_co, ChannelSelect[V_co]]], DecoratedItem[ChannelSelect[V_co]] @@ -244,12 +245,6 @@ def channel_select( custom_id: :class:`str` The ID of the select menu that gets received during an interaction. It is recommended not to set this parameter to prevent conflicts. - row: Optional[:class:`int`] - The relative row this select menu belongs to. A Discord component can only have 5 - rows. By default, items are arranged automatically into those 5 rows. If you'd - like to control the relative positioning of the row then passing an index is advised. - For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic - ordering. The row number must be between 0 and 4 (i.e. zero indexed). min_values: :class:`int` The minimum number of items that must be chosen for this select menu. Defaults to 1 and must be between 1 and 25. @@ -266,5 +261,17 @@ def channel_select( If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 + row: Optional[:class:`int`] + The relative row this select menu belongs to. A Discord component can only have 5 + rows. By default, items are arranged automatically into those 5 rows. If you'd + like to control the relative positioning of the row then passing an index is advised. + For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic + ordering. The row number must be between 0 and 4 (i.e. zero indexed). """ return _create_decorator(cls, **kwargs) diff --git a/disnake/ui/select/mentionable.py b/disnake/ui/select/mentionable.py index 69b79e6c12..c291c2899d 100644 --- a/disnake/ui/select/mentionable.py +++ b/disnake/ui/select/mentionable.py @@ -182,6 +182,7 @@ def mentionable_select( default_values: Optional[ Sequence[SelectDefaultValueMultiInputType[Union[User, Member, Role]]] ] = None, + id: int = 0, row: Optional[int] = None, ) -> Callable[ [ItemCallbackType[V_co, MentionableSelect[V_co]]], DecoratedItem[MentionableSelect[V_co]] @@ -219,12 +220,6 @@ def mentionable_select( custom_id: :class:`str` The ID of the select menu that gets received during an interaction. It is recommended not to set this parameter to prevent conflicts. - row: Optional[:class:`int`] - The relative row this select menu belongs to. A Discord component can only have 5 - rows. By default, items are arranged automatically into those 5 rows. If you'd - like to control the relative positioning of the row then passing an index is advised. - For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic - ordering. The row number must be between 0 and 4 (i.e. zero indexed). min_values: :class:`int` The minimum number of items that must be chosen for this select menu. Defaults to 1 and must be between 1 and 25. @@ -240,5 +235,17 @@ def mentionable_select( Note that unlike other select menu types, this does not support :class:`.Object`\\s due to ambiguities. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 + row: Optional[:class:`int`] + The relative row this select menu belongs to. A Discord component can only have 5 + rows. By default, items are arranged automatically into those 5 rows. If you'd + like to control the relative positioning of the row then passing an index is advised. + For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic + ordering. The row number must be between 0 and 4 (i.e. zero indexed). """ return _create_decorator(cls, **kwargs) diff --git a/disnake/ui/select/role.py b/disnake/ui/select/role.py index 812bc72bb7..0ff7d36b1b 100644 --- a/disnake/ui/select/role.py +++ b/disnake/ui/select/role.py @@ -169,6 +169,7 @@ def role_select( max_values: int = 1, disabled: bool = False, default_values: Optional[Sequence[SelectDefaultValueInputType[Role]]] = None, + id: int = 0, row: Optional[int] = None, ) -> Callable[[ItemCallbackType[V_co, RoleSelect[V_co]]], DecoratedItem[RoleSelect[V_co]]]: ... @@ -204,12 +205,6 @@ def role_select( custom_id: :class:`str` The ID of the select menu that gets received during an interaction. It is recommended not to set this parameter to prevent conflicts. - row: Optional[:class:`int`] - The relative row this select menu belongs to. A Discord component can only have 5 - rows. By default, items are arranged automatically into those 5 rows. If you'd - like to control the relative positioning of the row then passing an index is advised. - For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic - ordering. The row number must be between 0 and 4 (i.e. zero indexed). min_values: :class:`int` The minimum number of items that must be chosen for this select menu. Defaults to 1 and must be between 1 and 25. @@ -223,5 +218,17 @@ def role_select( If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 + row: Optional[:class:`int`] + The relative row this select menu belongs to. A Discord component can only have 5 + rows. By default, items are arranged automatically into those 5 rows. If you'd + like to control the relative positioning of the row then passing an index is advised. + For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic + ordering. The row number must be between 0 and 4 (i.e. zero indexed). """ return _create_decorator(cls, **kwargs) diff --git a/disnake/ui/select/string.py b/disnake/ui/select/string.py index 73c41aee23..7793ecc073 100644 --- a/disnake/ui/select/string.py +++ b/disnake/ui/select/string.py @@ -273,6 +273,7 @@ def string_select( max_values: int = 1, options: SelectOptionInput = ..., disabled: bool = False, + id: int = 0, row: Optional[int] = None, ) -> Callable[[ItemCallbackType[V_co, StringSelect[V_co]]], DecoratedItem[StringSelect[V_co]]]: ... @@ -311,12 +312,6 @@ def string_select( custom_id: :class:`str` The ID of the select menu that gets received during an interaction. It is recommended not to set this parameter to prevent conflicts. - row: Optional[:class:`int`] - The relative row this select menu belongs to. A Discord component can only have 5 - rows. By default, items are arranged automatically into those 5 rows. If you'd - like to control the relative positioning of the row then passing an index is advised. - For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic - ordering. The row number must be between 0 and 4 (i.e. zero indexed). min_values: :class:`int` The minimum number of items that must be chosen for this select menu. Defaults to 1 and must be between 1 and 25. @@ -334,6 +329,18 @@ def string_select( disabled: :class:`bool` Whether the select is disabled. Defaults to ``False``. + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 + row: Optional[:class:`int`] + The relative row this select menu belongs to. A Discord component can only have 5 + rows. By default, items are arranged automatically into those 5 rows. If you'd + like to control the relative positioning of the row then passing an index is advised. + For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic + ordering. The row number must be between 0 and 4 (i.e. zero indexed). """ return _create_decorator(cls, **kwargs) diff --git a/disnake/ui/select/user.py b/disnake/ui/select/user.py index 6cb19485ac..3d6262f8a5 100644 --- a/disnake/ui/select/user.py +++ b/disnake/ui/select/user.py @@ -171,6 +171,7 @@ def user_select( max_values: int = 1, disabled: bool = False, default_values: Optional[Sequence[SelectDefaultValueInputType[Union[User, Member]]]] = None, + id: int = 0, row: Optional[int] = None, ) -> Callable[[ItemCallbackType[V_co, UserSelect[V_co]]], DecoratedItem[UserSelect[V_co]]]: ... @@ -206,12 +207,6 @@ def user_select( custom_id: :class:`str` The ID of the select menu that gets received during an interaction. It is recommended not to set this parameter to prevent conflicts. - row: Optional[:class:`int`] - The relative row this select menu belongs to. A Discord component can only have 5 - rows. By default, items are arranged automatically into those 5 rows. If you'd - like to control the relative positioning of the row then passing an index is advised. - For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic - ordering. The row number must be between 0 and 4 (i.e. zero indexed). min_values: :class:`int` The minimum number of items that must be chosen for this select menu. Defaults to 1 and must be between 1 and 25. @@ -225,5 +220,17 @@ def user_select( If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + If left unset (i.e. the default ``0``) when sending a component, the API will assign + sequential identifiers to the components in the message. + + .. versionadded:: 2.11 + row: Optional[:class:`int`] + The relative row this select menu belongs to. A Discord component can only have 5 + rows. By default, items are arranged automatically into those 5 rows. If you'd + like to control the relative positioning of the row then passing an index is advised. + For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic + ordering. The row number must be between 0 and 4 (i.e. zero indexed). """ return _create_decorator(cls, **kwargs) From 035e03659b8787def52a1d6da69ed042cce10ae4 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 9 Aug 2025 12:32:48 +0200 Subject: [PATCH 056/104] test: fix broken `components_to_dict` test --- tests/ui/test_action_row.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/ui/test_action_row.py b/tests/ui/test_action_row.py index 6deb2e5320..a96b5d4736 100644 --- a/tests/ui/test_action_row.py +++ b/tests/ui/test_action_row.py @@ -263,14 +263,17 @@ def test_components_to_dict() -> None: assert result == [ { "type": 1, + "id": 0, "components": [button1.to_component_dict(), button2.to_component_dict()], }, { "type": 1, + "id": 0, "components": [select.to_component_dict()], }, { "type": 1, + "id": 0, "components": [button3.to_component_dict()], }, ] From 49582653e8fa6f4a5582451a7c24a6a9348902d6 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 9 Aug 2025 20:29:53 +0200 Subject: [PATCH 057/104] docs: note that `id` must be unique per message --- disnake/ui/action_row.py | 2 +- disnake/ui/button.py | 4 ++-- disnake/ui/container.py | 2 +- disnake/ui/file.py | 2 +- disnake/ui/media_gallery.py | 2 +- disnake/ui/section.py | 2 +- disnake/ui/select/channel.py | 4 ++-- disnake/ui/select/mentionable.py | 4 ++-- disnake/ui/select/role.py | 4 ++-- disnake/ui/select/string.py | 4 ++-- disnake/ui/select/user.py | 4 ++-- disnake/ui/separator.py | 2 +- disnake/ui/text_display.py | 2 +- disnake/ui/text_input.py | 2 +- disnake/ui/thumbnail.py | 2 +- 15 files changed, 21 insertions(+), 21 deletions(-) diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index 59b3b7d642..0413a7d04b 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -158,7 +158,7 @@ class ActionRow(UIComponent, Generic[ActionRowChildT]): Components can now be either valid in the context of a message, or in the context of a modal. Combining components from both contexts is not supported. id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. diff --git a/disnake/ui/button.py b/disnake/ui/button.py index d8df90ab6e..4fd1dbbe52 100644 --- a/disnake/ui/button.py +++ b/disnake/ui/button.py @@ -69,7 +69,7 @@ class Button(Item[V_co]): .. versionadded:: 2.11 id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. @@ -349,7 +349,7 @@ def button( The emoji of the button. This can be in string form or a :class:`.PartialEmoji` or a full :class:`.Emoji`. id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. diff --git a/disnake/ui/container.py b/disnake/ui/container.py index fb5cc5bb15..7f94b6666f 100644 --- a/disnake/ui/container.py +++ b/disnake/ui/container.py @@ -46,7 +46,7 @@ class Container(UIComponent): spoiler: :class:`bool` Whether the container is marked as a spoiler. Defaults to ``False``. id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. diff --git a/disnake/ui/file.py b/disnake/ui/file.py index d940290625..82c119e5d5 100644 --- a/disnake/ui/file.py +++ b/disnake/ui/file.py @@ -28,7 +28,7 @@ class File(UIComponent): spoiler: :class:`bool` Whether the file is marked as a spoiler. Defaults to ``False``. id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. """ diff --git a/disnake/ui/media_gallery.py b/disnake/ui/media_gallery.py index c0fc909e6a..31b723f270 100644 --- a/disnake/ui/media_gallery.py +++ b/disnake/ui/media_gallery.py @@ -22,7 +22,7 @@ class MediaGallery(UIComponent): *items: :class:`.MediaGalleryItem` The list of images in this gallery. id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. """ diff --git a/disnake/ui/section.py b/disnake/ui/section.py index c3008d84b8..ba6ed05dd2 100644 --- a/disnake/ui/section.py +++ b/disnake/ui/section.py @@ -29,7 +29,7 @@ class Section(UIComponent): accessory: Union[:class:`~.ui.Thumbnail`, :class:`~.ui.Button`] The accessory component displayed next to the section text. id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. """ diff --git a/disnake/ui/select/channel.py b/disnake/ui/select/channel.py index 074717bfe6..b70bb8fc47 100644 --- a/disnake/ui/select/channel.py +++ b/disnake/ui/select/channel.py @@ -72,7 +72,7 @@ class ChannelSelect(BaseSelect[ChannelSelectMenu, "AnyChannel", V_co]): .. versionadded:: 2.10 id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. @@ -262,7 +262,7 @@ def channel_select( .. versionadded:: 2.10 id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. diff --git a/disnake/ui/select/mentionable.py b/disnake/ui/select/mentionable.py index c291c2899d..5cb529a501 100644 --- a/disnake/ui/select/mentionable.py +++ b/disnake/ui/select/mentionable.py @@ -70,7 +70,7 @@ class MentionableSelect(BaseSelect[MentionableSelectMenu, "Union[User, Member, R .. versionadded:: 2.10 id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. @@ -236,7 +236,7 @@ def mentionable_select( .. versionadded:: 2.10 id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. diff --git a/disnake/ui/select/role.py b/disnake/ui/select/role.py index 0ff7d36b1b..bf27d55168 100644 --- a/disnake/ui/select/role.py +++ b/disnake/ui/select/role.py @@ -66,7 +66,7 @@ class RoleSelect(BaseSelect[RoleSelectMenu, "Role", V_co]): .. versionadded:: 2.10 id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. @@ -219,7 +219,7 @@ def role_select( .. versionadded:: 2.10 id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. diff --git a/disnake/ui/select/string.py b/disnake/ui/select/string.py index 7793ecc073..388db2cf60 100644 --- a/disnake/ui/select/string.py +++ b/disnake/ui/select/string.py @@ -87,7 +87,7 @@ class StringSelect(BaseSelect[StringSelectMenu, str, V_co]): disabled: :class:`bool` Whether the select is disabled. id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. @@ -330,7 +330,7 @@ def string_select( disabled: :class:`bool` Whether the select is disabled. Defaults to ``False``. id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. diff --git a/disnake/ui/select/user.py b/disnake/ui/select/user.py index 3d6262f8a5..e2207affa8 100644 --- a/disnake/ui/select/user.py +++ b/disnake/ui/select/user.py @@ -68,7 +68,7 @@ class UserSelect(BaseSelect[UserSelectMenu, "Union[User, Member]", V_co]): .. versionadded:: 2.10 id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. @@ -221,7 +221,7 @@ def user_select( .. versionadded:: 2.10 id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. diff --git a/disnake/ui/separator.py b/disnake/ui/separator.py index 8bcb86e5d1..946e3cb561 100644 --- a/disnake/ui/separator.py +++ b/disnake/ui/separator.py @@ -26,7 +26,7 @@ class Separator(UIComponent): The size of the separator. Defaults to :attr:`~.SeparatorSpacingSize.small`. id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. """ diff --git a/disnake/ui/text_display.py b/disnake/ui/text_display.py index f26c91f5b8..5ed240b30b 100644 --- a/disnake/ui/text_display.py +++ b/disnake/ui/text_display.py @@ -23,7 +23,7 @@ class TextDisplay(UIComponent): content: :class:`str` The text displayed by this component. id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. """ diff --git a/disnake/ui/text_input.py b/disnake/ui/text_input.py index 4b7a41daf9..d63184d531 100644 --- a/disnake/ui/text_input.py +++ b/disnake/ui/text_input.py @@ -38,7 +38,7 @@ class TextInput(WrappedComponent): max_length: Optional[:class:`int`] The maximum length of the text input. id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. diff --git a/disnake/ui/thumbnail.py b/disnake/ui/thumbnail.py index a2d0402e94..5e593eae38 100644 --- a/disnake/ui/thumbnail.py +++ b/disnake/ui/thumbnail.py @@ -32,7 +32,7 @@ class Thumbnail(UIComponent): spoiler: :class:`bool` Whether the thumbnail is marked as a spoiler. Defaults to ``False``. id: :class:`int` - The numeric identifier for the component. + The numeric identifier for the component. Must be unique within the message. If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential identifiers to the components in the message. """ From 41cc252778fae9ad337738cebe224c76256a99a6 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 9 Aug 2025 20:32:41 +0200 Subject: [PATCH 058/104] fix(types): add `id` to `ModalInteractionActionRow` typeddict --- disnake/types/interactions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/disnake/types/interactions.py b/disnake/types/interactions.py index 393e0a208f..1573cce925 100644 --- a/disnake/types/interactions.py +++ b/disnake/types/interactions.py @@ -226,7 +226,6 @@ class MessageComponentInteractionChannelSelectData(_BaseSnowflakeComponentIntera ### Modal interaction components -# TODO: add other select types class ModalInteractionStringSelectData(_BaseComponentInteractionData): type: Literal[3] values: List[str] @@ -245,6 +244,7 @@ class ModalInteractionTextInputData(_BaseComponentInteractionData): class ModalInteractionActionRow(TypedDict): type: Literal[1] + id: int components: List[ModalInteractionComponentData] From 932982c186d84af0c7c42423a4c1091298fba1d0 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 9 Aug 2025 22:03:55 +0200 Subject: [PATCH 059/104] chore(docs): rephrase `id` default note Co-authored-by: Eneg <42005170+Enegg@users.noreply.github.com> --- disnake/components.py | 2 +- disnake/ui/action_row.py | 16 ++++++++-------- disnake/ui/button.py | 4 ++-- disnake/ui/container.py | 2 +- disnake/ui/file.py | 2 +- disnake/ui/media_gallery.py | 2 +- disnake/ui/section.py | 2 +- disnake/ui/select/channel.py | 4 ++-- disnake/ui/select/mentionable.py | 4 ++-- disnake/ui/select/role.py | 4 ++-- disnake/ui/select/string.py | 4 ++-- disnake/ui/select/user.py | 4 ++-- disnake/ui/separator.py | 2 +- disnake/ui/text_display.py | 2 +- disnake/ui/text_input.py | 2 +- disnake/ui/thumbnail.py | 2 +- 16 files changed, 29 insertions(+), 29 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 61c01dca8c..e0dc327640 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -175,7 +175,7 @@ class Component: The numeric identifier for the component. This is always present in components received from the API, and unique within a message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign sequential + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index 0413a7d04b..087d57b59a 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -159,7 +159,7 @@ class ActionRow(UIComponent, Generic[ActionRowChildT]): context of a modal. Combining components from both contexts is not supported. id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 @@ -330,7 +330,7 @@ def add_button( .. versionadded:: 2.11 id: :class:`int` The numeric identifier for the component. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 @@ -398,7 +398,7 @@ def add_string_select( Whether the select is disabled or not. id: :class:`int` The numeric identifier for the component. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 @@ -466,7 +466,7 @@ def add_user_select( .. versionadded:: 2.10 id: :class:`int` The numeric identifier for the component. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 @@ -532,7 +532,7 @@ def add_role_select( .. versionadded:: 2.10 id: :class:`int` The numeric identifier for the component. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 @@ -602,7 +602,7 @@ def add_mentionable_select( .. versionadded:: 2.10 id: :class:`int` The numeric identifier for the component. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 @@ -672,7 +672,7 @@ def add_channel_select( .. versionadded:: 2.10 id: :class:`int` The numeric identifier for the component. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 @@ -739,7 +739,7 @@ def add_text_input( The maximum length of the text input. id: :class:`int` The numeric identifier for the component. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 diff --git a/disnake/ui/button.py b/disnake/ui/button.py index 4fd1dbbe52..496db1e5e1 100644 --- a/disnake/ui/button.py +++ b/disnake/ui/button.py @@ -70,7 +70,7 @@ class Button(Item[V_co]): .. versionadded:: 2.11 id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 @@ -350,7 +350,7 @@ def button( or a full :class:`.Emoji`. id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 diff --git a/disnake/ui/container.py b/disnake/ui/container.py index 7f94b6666f..77d41d314b 100644 --- a/disnake/ui/container.py +++ b/disnake/ui/container.py @@ -47,7 +47,7 @@ class Container(UIComponent): Whether the container is marked as a spoiler. Defaults to ``False``. id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. Attributes diff --git a/disnake/ui/file.py b/disnake/ui/file.py index 82c119e5d5..ed2270d49b 100644 --- a/disnake/ui/file.py +++ b/disnake/ui/file.py @@ -29,7 +29,7 @@ class File(UIComponent): Whether the file is marked as a spoiler. Defaults to ``False``. id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. """ diff --git a/disnake/ui/media_gallery.py b/disnake/ui/media_gallery.py index 31b723f270..2d135d3a9b 100644 --- a/disnake/ui/media_gallery.py +++ b/disnake/ui/media_gallery.py @@ -23,7 +23,7 @@ class MediaGallery(UIComponent): The list of images in this gallery. id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. """ diff --git a/disnake/ui/section.py b/disnake/ui/section.py index ba6ed05dd2..6fb4ae429b 100644 --- a/disnake/ui/section.py +++ b/disnake/ui/section.py @@ -30,7 +30,7 @@ class Section(UIComponent): The accessory component displayed next to the section text. id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. """ diff --git a/disnake/ui/select/channel.py b/disnake/ui/select/channel.py index b70bb8fc47..a10ba03911 100644 --- a/disnake/ui/select/channel.py +++ b/disnake/ui/select/channel.py @@ -73,7 +73,7 @@ class ChannelSelect(BaseSelect[ChannelSelectMenu, "AnyChannel", V_co]): .. versionadded:: 2.10 id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 @@ -263,7 +263,7 @@ def channel_select( .. versionadded:: 2.10 id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 diff --git a/disnake/ui/select/mentionable.py b/disnake/ui/select/mentionable.py index 5cb529a501..536a58608d 100644 --- a/disnake/ui/select/mentionable.py +++ b/disnake/ui/select/mentionable.py @@ -71,7 +71,7 @@ class MentionableSelect(BaseSelect[MentionableSelectMenu, "Union[User, Member, R .. versionadded:: 2.10 id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 @@ -237,7 +237,7 @@ def mentionable_select( .. versionadded:: 2.10 id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 diff --git a/disnake/ui/select/role.py b/disnake/ui/select/role.py index bf27d55168..5cc6acf7e6 100644 --- a/disnake/ui/select/role.py +++ b/disnake/ui/select/role.py @@ -67,7 +67,7 @@ class RoleSelect(BaseSelect[RoleSelectMenu, "Role", V_co]): .. versionadded:: 2.10 id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 @@ -220,7 +220,7 @@ def role_select( .. versionadded:: 2.10 id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 diff --git a/disnake/ui/select/string.py b/disnake/ui/select/string.py index 388db2cf60..d381a30411 100644 --- a/disnake/ui/select/string.py +++ b/disnake/ui/select/string.py @@ -88,7 +88,7 @@ class StringSelect(BaseSelect[StringSelectMenu, str, V_co]): Whether the select is disabled. id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 @@ -331,7 +331,7 @@ def string_select( Whether the select is disabled. Defaults to ``False``. id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 diff --git a/disnake/ui/select/user.py b/disnake/ui/select/user.py index e2207affa8..cb086f9f5e 100644 --- a/disnake/ui/select/user.py +++ b/disnake/ui/select/user.py @@ -69,7 +69,7 @@ class UserSelect(BaseSelect[UserSelectMenu, "Union[User, Member]", V_co]): .. versionadded:: 2.10 id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 @@ -222,7 +222,7 @@ def user_select( .. versionadded:: 2.10 id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 diff --git a/disnake/ui/separator.py b/disnake/ui/separator.py index 946e3cb561..e6b0e6eb1c 100644 --- a/disnake/ui/separator.py +++ b/disnake/ui/separator.py @@ -27,7 +27,7 @@ class Separator(UIComponent): Defaults to :attr:`~.SeparatorSpacingSize.small`. id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. """ diff --git a/disnake/ui/text_display.py b/disnake/ui/text_display.py index 5ed240b30b..33fc41bfd3 100644 --- a/disnake/ui/text_display.py +++ b/disnake/ui/text_display.py @@ -24,7 +24,7 @@ class TextDisplay(UIComponent): The text displayed by this component. id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. """ diff --git a/disnake/ui/text_input.py b/disnake/ui/text_input.py index d63184d531..4cb70707aa 100644 --- a/disnake/ui/text_input.py +++ b/disnake/ui/text_input.py @@ -39,7 +39,7 @@ class TextInput(WrappedComponent): The maximum length of the text input. id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. .. versionadded:: 2.11 diff --git a/disnake/ui/thumbnail.py b/disnake/ui/thumbnail.py index 5e593eae38..066d7f9d14 100644 --- a/disnake/ui/thumbnail.py +++ b/disnake/ui/thumbnail.py @@ -33,7 +33,7 @@ class Thumbnail(UIComponent): Whether the thumbnail is marked as a spoiler. Defaults to ``False``. id: :class:`int` The numeric identifier for the component. Must be unique within the message. - If left unset (i.e. the default ``0``) when sending a component, the API will assign + If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. """ From 3edad5ce477483c17ca66d408f6d61d4288f1d4d Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 9 Aug 2025 22:07:15 +0200 Subject: [PATCH 060/104] fix(docs): add AssetMixin + Attachment to supported media input types --- disnake/ui/file.py | 2 +- disnake/ui/thumbnail.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/disnake/ui/file.py b/disnake/ui/file.py index ed2270d49b..3f57568526 100644 --- a/disnake/ui/file.py +++ b/disnake/ui/file.py @@ -22,7 +22,7 @@ class File(UIComponent): Parameters ---------- - file: Union[:class:`str`, :class:`.UnfurledMediaItem`] + file: Union[:class:`str`, :class:`.AssetMixin`, :class:`.Attachment`, :class:`.UnfurledMediaItem`] The file to display. This **only** supports attachment references (i.e. using the ``attachment://`` syntax), not arbitrary URLs. spoiler: :class:`bool` diff --git a/disnake/ui/thumbnail.py b/disnake/ui/thumbnail.py index 066d7f9d14..f157fb6b51 100644 --- a/disnake/ui/thumbnail.py +++ b/disnake/ui/thumbnail.py @@ -24,7 +24,7 @@ class Thumbnail(UIComponent): Parameters ---------- - media: Union[:class:`str`, :class:`.UnfurledMediaItem`] + media: Union[:class:`str`, :class:`.AssetMixin`, :class:`.Attachment`, :class:`.UnfurledMediaItem`] The media item to display. Can be an arbitrary URL or attachment reference (``attachment://``). description: Optional[:class:`str`] From 81801a3d540f2087ea18c4a4f8a64f5ba3d95555 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 9 Aug 2025 22:15:13 +0200 Subject: [PATCH 061/104] fix(docs): `AssetMixin` isn't documented, `Asset` is fine for now. --- disnake/ui/file.py | 2 +- disnake/ui/thumbnail.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/disnake/ui/file.py b/disnake/ui/file.py index 3f57568526..b26db72f78 100644 --- a/disnake/ui/file.py +++ b/disnake/ui/file.py @@ -22,7 +22,7 @@ class File(UIComponent): Parameters ---------- - file: Union[:class:`str`, :class:`.AssetMixin`, :class:`.Attachment`, :class:`.UnfurledMediaItem`] + file: Union[:class:`str`, :class:`.Asset`, :class:`.Attachment`, :class:`.UnfurledMediaItem`] The file to display. This **only** supports attachment references (i.e. using the ``attachment://`` syntax), not arbitrary URLs. spoiler: :class:`bool` diff --git a/disnake/ui/thumbnail.py b/disnake/ui/thumbnail.py index f157fb6b51..cd071d2dff 100644 --- a/disnake/ui/thumbnail.py +++ b/disnake/ui/thumbnail.py @@ -24,7 +24,7 @@ class Thumbnail(UIComponent): Parameters ---------- - media: Union[:class:`str`, :class:`.AssetMixin`, :class:`.Attachment`, :class:`.UnfurledMediaItem`] + media: Union[:class:`str`, :class:`.Asset`, :class:`.Attachment`, :class:`.UnfurledMediaItem`] The media item to display. Can be an arbitrary URL or attachment reference (``attachment://``). description: Optional[:class:`str`] From 465feca24b4c31ebce274262a67bb63b629f9349 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sun, 10 Aug 2025 13:19:34 +0200 Subject: [PATCH 062/104] refactor: move `handle_media_item_input` to .components --- disnake/components.py | 22 +++++++++++++++++++++- disnake/ui/_types.py | 6 ------ disnake/ui/file.py | 6 +++--- disnake/ui/item.py | 19 ------------------- disnake/ui/thumbnail.py | 6 +++--- 5 files changed, 27 insertions(+), 32 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index e0dc327640..d825a83b88 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -35,12 +35,13 @@ try_enum, ) from .partial_emoji import PartialEmoji, _EmojiTag -from .utils import MISSING, _get_as_snowflake, get_slots +from .utils import MISSING, _get_as_snowflake, assert_never, get_slots if TYPE_CHECKING: from typing_extensions import Self, TypeAlias from .emoji import Emoji + from .message import Attachment from .types.components import ( ActionRow as ActionRowPayload, AnySelectMenu as AnySelectMenuPayload, @@ -68,6 +69,8 @@ UserSelectMenu as UserSelectMenuPayload, ) + MediaItemInput = Union[str, "AssetMixin", "Attachment", "UnfurledMediaItem"] + __all__ = ( "Component", "ActionRow", @@ -1310,6 +1313,23 @@ def _walk_all_components(components: Sequence[Component]) -> Iterator[Component] yield from _walk_internal(item, seen) +def handle_media_item_input(value: MediaItemInput) -> UnfurledMediaItem: + if isinstance(value, UnfurledMediaItem): + return value + elif isinstance(value, str): + return UnfurledMediaItem(value) + + # circular import + from .message import Attachment + + if isinstance(value, (AssetMixin, Attachment)): + return UnfurledMediaItem(value.url) + + # TODO: raise proper exception (?) + assert_never(value) + return value + + C = TypeVar("C", bound="Component") diff --git a/disnake/ui/_types.py b/disnake/ui/_types.py index bb53b48df4..a5ee800603 100644 --- a/disnake/ui/_types.py +++ b/disnake/ui/_types.py @@ -7,9 +7,6 @@ if TYPE_CHECKING: from typing_extensions import TypeAlias - from ..asset import AssetMixin - from ..components import UnfurledMediaItem - from ..message import Attachment from . import ( ActionRow, Button, @@ -78,6 +75,3 @@ MessageComponents = ComponentInput[ActionRowMessageComponent, MessageTopLevelComponentV2] ModalComponents = ComponentInput[ActionRowModalComponent, NoReturn] - - -MediaItemInput = Union[str, "AssetMixin", "Attachment", "UnfurledMediaItem"] diff --git a/disnake/ui/file.py b/disnake/ui/file.py index b26db72f78..f53b4804ba 100644 --- a/disnake/ui/file.py +++ b/disnake/ui/file.py @@ -4,13 +4,13 @@ from typing import TYPE_CHECKING, ClassVar, Tuple -from ..components import FileComponent, UnfurledMediaItem +from ..components import FileComponent, UnfurledMediaItem, handle_media_item_input from ..enums import ComponentType from ..utils import MISSING -from .item import UIComponent, handle_media_item_input +from .item import UIComponent if TYPE_CHECKING: - from ._types import MediaItemInput + from ..components import MediaItemInput __all__ = ("File",) diff --git a/disnake/ui/item.py b/disnake/ui/item.py index 0e7a810757..0d1a1b2eb2 100644 --- a/disnake/ui/item.py +++ b/disnake/ui/item.py @@ -19,11 +19,6 @@ overload, ) -from ..asset import AssetMixin -from ..components import UnfurledMediaItem -from ..message import Attachment -from ..utils import assert_never - __all__ = ( "UIComponent", "WrappedComponent", @@ -41,7 +36,6 @@ from ..enums import ComponentType from ..interactions import MessageInteraction from ..types.components import ActionRowChildComponent as ActionRowChildComponentPayload - from ._types import MediaItemInput from .view import View ItemCallbackType = Callable[[V_co, I, MessageInteraction], Coroutine[Any, Any, Any]] @@ -56,19 +50,6 @@ def ensure_ui_component(obj: UIComponentT, name: str) -> UIComponentT: return obj -def handle_media_item_input(value: MediaItemInput) -> UnfurledMediaItem: - if isinstance(value, UnfurledMediaItem): - return value - elif isinstance(value, str): - return UnfurledMediaItem(value) - elif isinstance(value, (AssetMixin, Attachment)): - return UnfurledMediaItem(value.url) - - # TODO: raise proper exception (?) - assert_never(value) - return value - - class UIComponent(ABC): """Represents the base UI component that all UI components inherit from. diff --git a/disnake/ui/thumbnail.py b/disnake/ui/thumbnail.py index cd071d2dff..55dbfd7e11 100644 --- a/disnake/ui/thumbnail.py +++ b/disnake/ui/thumbnail.py @@ -4,13 +4,13 @@ from typing import TYPE_CHECKING, ClassVar, Optional, Tuple -from ..components import Thumbnail as ThumbnailComponent, UnfurledMediaItem +from ..components import Thumbnail as ThumbnailComponent, UnfurledMediaItem, handle_media_item_input from ..enums import ComponentType from ..utils import MISSING -from .item import UIComponent, handle_media_item_input +from .item import UIComponent if TYPE_CHECKING: - from ._types import MediaItemInput + from ..components import MediaItemInput __all__ = ("Thumbnail",) From c6a780407638e6159908f1a52c89c31789ffa5e5 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sun, 10 Aug 2025 13:24:10 +0200 Subject: [PATCH 063/104] feat: make `MediaGalleryItem` user-instantiable --- disnake/components.py | 42 ++++++++++++++++++++++++++++++------- disnake/ui/media_gallery.py | 1 - 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index d825a83b88..8f2d42afef 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -1082,7 +1082,7 @@ def __init__(self, data: MediaGalleryComponentPayload) -> None: self.type: Literal[ComponentType.media_gallery] = ComponentType.media_gallery self.id = data.get("id", 0) - self.items: List[MediaGalleryItem] = [MediaGalleryItem(i) for i in data["items"]] + self.items: List[MediaGalleryItem] = [MediaGalleryItem.from_dict(i) for i in data["items"]] def to_dict(self) -> MediaGalleryComponentPayload: return { @@ -1093,7 +1093,20 @@ def to_dict(self) -> MediaGalleryComponentPayload: class MediaGalleryItem: - """TODO""" + """Represents an item inside a :class:`MediaGallery`. + + .. versionadded:: 2.11 + + Parameters + ---------- + media: Union[:class:`str`, :class:`.Asset`, :class:`.Attachment`, :class:`.UnfurledMediaItem`] + The media item to display. Can be an arbitrary URL or attachment + reference (``attachment://``). + description: Optional[:class:`str`] + The item's description ("alt text"), if any. + spoiler: :class:`bool` + Whether the item is marked as a spoiler. Defaults to ``False``. + """ __slots__: Tuple[str, ...] = ( "media", @@ -1101,11 +1114,25 @@ class MediaGalleryItem: "spoiler", ) - # XXX: should this be user-instantiable? - def __init__(self, data: MediaGalleryItemPayload) -> None: - self.media: UnfurledMediaItem = UnfurledMediaItem.from_dict(data["media"]) - self.description: Optional[str] = data.get("description") - self.spoiler: bool = data.get("spoiler", False) + def __init__( + self, + media: MediaItemInput, + description: Optional[str] = None, + *, + spoiler: bool = False, + ) -> None: + self.media: UnfurledMediaItem = handle_media_item_input(media) + self.description: Optional[str] = description + self.spoiler: bool = spoiler + + # FIXME: when deserializing, try to attach _state for `self.media` as well + @classmethod + def from_dict(cls, data: MediaGalleryItemPayload) -> Self: + return cls( + media=UnfurledMediaItem.from_dict(data["media"]), + description=data.get("description"), + spoiler=data.get("spoiler", False), + ) def to_dict(self) -> MediaGalleryItemPayload: payload: MediaGalleryItemPayload = { @@ -1122,7 +1149,6 @@ def __repr__(self) -> str: return f"" -# TODO: temporary(?) name to avoid shadowing `disnake.file.File` class FileComponent(Component): """Represents a file component from the Discord Bot UI Kit (v2). diff --git a/disnake/ui/media_gallery.py b/disnake/ui/media_gallery.py index 2d135d3a9b..252724f3f9 100644 --- a/disnake/ui/media_gallery.py +++ b/disnake/ui/media_gallery.py @@ -31,7 +31,6 @@ class MediaGallery(UIComponent): # We have to set this to MISSING in order to overwrite the abstract property from UIComponent _underlying: MediaGalleryComponent = MISSING - # FIXME: MediaGalleryItem currently isn't user-instantiable def __init__(self, *items: MediaGalleryItem, id: int = 0) -> None: self._underlying = MediaGalleryComponent._raw_construct( type=ComponentType.media_gallery, From 8d3fe20fb1c6a8bffe2570d70ee47c2826fe4a1b Mon Sep 17 00:00:00 2001 From: shiftinv Date: Mon, 11 Aug 2025 18:54:42 +0200 Subject: [PATCH 064/104] docs: clarify `Separator.spacing` description --- disnake/components.py | 3 +-- disnake/ui/separator.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 8f2d42afef..f59a59c54a 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -1205,7 +1205,7 @@ class Separator(Component): Whether the separator should be visible, instead of just being vertical padding/spacing. Defaults to ``True``. spacing: :class:`SeparatorSpacingSize` - The size of the separator. + The size of the separator padding. """ __slots__: Tuple[str, ...] = ("divider", "spacing") @@ -1217,7 +1217,6 @@ def __init__(self, data: SeparatorComponentPayload) -> None: self.id = data.get("id", 0) self.divider: bool = data.get("divider", True) - # TODO: `size` instead of `spacing`? self.spacing: SeparatorSpacingSize = try_enum(SeparatorSpacingSize, data.get("spacing", 1)) def to_dict(self) -> SeparatorComponentPayload: diff --git a/disnake/ui/separator.py b/disnake/ui/separator.py index e6b0e6eb1c..a9b944fa20 100644 --- a/disnake/ui/separator.py +++ b/disnake/ui/separator.py @@ -23,7 +23,7 @@ class Separator(UIComponent): Whether the separator should be visible, instead of just being vertical padding/spacing. Defaults to ``True``. spacing: :class:`.SeparatorSpacingSize` - The size of the separator. + The size of the separator padding. Defaults to :attr:`~.SeparatorSpacingSize.small`. id: :class:`int` The numeric identifier for the component. Must be unique within the message. From ae8e4c937ccf2dd0c88e1feb6de84fe6c022bb40 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Mon, 11 Aug 2025 19:06:12 +0200 Subject: [PATCH 065/104] fix: raise if `ui.File` media url is not an `attachment://` --- disnake/components.py | 3 ++- disnake/ui/file.py | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index f59a59c54a..c344c2e75b 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -69,7 +69,8 @@ UserSelectMenu as UserSelectMenuPayload, ) - MediaItemInput = Union[str, "AssetMixin", "Attachment", "UnfurledMediaItem"] + LocalMediaItemInput = Union[str, "UnfurledMediaItem"] + MediaItemInput = Union[LocalMediaItemInput, "AssetMixin", "Attachment"] __all__ = ( "Component", diff --git a/disnake/ui/file.py b/disnake/ui/file.py index f53b4804ba..b3498b2f1c 100644 --- a/disnake/ui/file.py +++ b/disnake/ui/file.py @@ -10,7 +10,7 @@ from .item import UIComponent if TYPE_CHECKING: - from ..components import MediaItemInput + from ..components import LocalMediaItemInput __all__ = ("File",) @@ -22,7 +22,7 @@ class File(UIComponent): Parameters ---------- - file: Union[:class:`str`, :class:`.Asset`, :class:`.Attachment`, :class:`.UnfurledMediaItem`] + file: Union[:class:`str`, :class:`.UnfurledMediaItem`] The file to display. This **only** supports attachment references (i.e. using the ``attachment://`` syntax), not arbitrary URLs. spoiler: :class:`bool` @@ -42,15 +42,19 @@ class File(UIComponent): def __init__( self, - file: MediaItemInput, + file: LocalMediaItemInput, *, spoiler: bool = False, id: int = 0, ) -> None: + file_media = handle_media_item_input(file) + if not file_media.url.startswith("attachment://"): + raise ValueError("File component does not support external media URLs") + self._underlying = FileComponent._raw_construct( type=ComponentType.file, id=id, - file=handle_media_item_input(file), + file=file_media, spoiler=spoiler, ) @@ -60,8 +64,11 @@ def file(self) -> UnfurledMediaItem: return self._underlying.file @file.setter - def file(self, value: MediaItemInput) -> None: - self._underlying.file = handle_media_item_input(value) + def file(self, value: LocalMediaItemInput) -> None: + file_media = handle_media_item_input(value) + if not file_media.url.startswith("attachment://"): + raise ValueError("File component does not support external media URLs") + self._underlying.file = handle_media_item_input(file_media) @property def spoiler(self) -> bool: From 2dc64f04075780606706bb99e0c29fc8af240e59 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Mon, 11 Aug 2025 19:10:50 +0200 Subject: [PATCH 066/104] fix: raise TypeError in `handle_media_item_input` for invalid inputs --- disnake/components.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index c344c2e75b..3657a73f1b 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -1351,9 +1351,8 @@ def handle_media_item_input(value: MediaItemInput) -> UnfurledMediaItem: if isinstance(value, (AssetMixin, Attachment)): return UnfurledMediaItem(value.url) - # TODO: raise proper exception (?) assert_never(value) - return value + raise TypeError(f"{type(value).__name__} cannot be converted to UnfurledMediaItem") C = TypeVar("C", bound="Component") From ea3c67aff9043d135cfd38aaec1665689b5239a3 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Mon, 11 Aug 2025 19:31:15 +0200 Subject: [PATCH 067/104] feat: remove `Section./Container.components` attribute proxy --- disnake/ui/container.py | 20 +++++++++----------- disnake/ui/section.py | 36 +++++++++++++++++------------------- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/disnake/ui/container.py b/disnake/ui/container.py index 77d41d314b..2c560e142b 100644 --- a/disnake/ui/container.py +++ b/disnake/ui/container.py @@ -2,12 +2,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar, List, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, ClassVar, List, Optional, Tuple, Union from ..colour import Colour from ..components import Container as ContainerComponent from ..enums import ComponentType -from ..utils import SequenceProxy, copy_doc +from ..utils import copy_doc from .item import UIComponent, ensure_ui_component if TYPE_CHECKING: @@ -52,6 +52,8 @@ class Container(UIComponent): Attributes ---------- + components: List[Union[:class:`~.ui.ActionRow`, :class:`~.ui.Section`, :class:`~.ui.TextDisplay`, :class:`~.ui.MediaGallery`, :class:`~.ui.File`, :class:`~.ui.Separator`]] + The list of components in this container. accent_colour: Optional[:class:`.Colour`] The accent colour of the container. spoiler: :class:`bool` @@ -59,7 +61,7 @@ class Container(UIComponent): """ __repr_attributes__: ClassVar[Tuple[str, ...]] = ( - "_components", + "components", "accent_colour", "spoiler", ) @@ -73,7 +75,9 @@ def __init__( id: int = 0, ) -> None: self._id: int = id - self._components: List[ContainerChildUIComponent] = [ + # this list can be modified without any runtime checks later on, + # just assume the user knows what they're doing at that point + self.components: List[ContainerChildUIComponent] = [ ensure_ui_component(c, "components") for c in components ] # FIXME: add accent_color @@ -91,18 +95,12 @@ def id(self) -> int: def id(self, value: int) -> None: self._id = value - # TODO: consider moving runtime checks from constructor into property setters, also making these fields writable - @property - def components(self) -> Sequence[ContainerChildUIComponent]: - """Sequence[Union[:class:`~.ui.ActionRow`, :class:`~.ui.Section`, :class:`~.ui.TextDisplay`, :class:`~.ui.MediaGallery`, :class:`~.ui.File`, :class:`~.ui.Separator`]]: A read-only proxy of the components in this container.""" - return SequenceProxy(self._components) - @property def _underlying(self) -> ContainerComponent: return ContainerComponent._raw_construct( type=ComponentType.container, id=self._id, - components=[comp._underlying for comp in self._components], + components=[comp._underlying for comp in self.components], _accent_colour=self.accent_colour.value if self.accent_colour is not None else None, spoiler=self.spoiler, ) diff --git a/disnake/ui/section.py b/disnake/ui/section.py index 6fb4ae429b..99f04a30fb 100644 --- a/disnake/ui/section.py +++ b/disnake/ui/section.py @@ -2,11 +2,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, ClassVar, List, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Any, ClassVar, List, Tuple, Union from ..components import Section as SectionComponent from ..enums import ComponentType -from ..utils import SequenceProxy, copy_doc +from ..utils import copy_doc from .item import UIComponent, ensure_ui_component if TYPE_CHECKING: @@ -25,17 +25,24 @@ class Section(UIComponent): Parameters ---------- *components: :class:`~.ui.TextDisplay` - The list of text items in this section. + The text items in this section. accessory: Union[:class:`~.ui.Thumbnail`, :class:`~.ui.Button`] The accessory component displayed next to the section text. id: :class:`int` The numeric identifier for the component. Must be unique within the message. If set to ``0`` (the default) when sending a component, the API will assign sequential identifiers to the components in the message. + + Attributes + ---------- + components: List[:class:`~.ui.TextDisplay`] + The list of text items in this section. + accessory: Union[:class:`~.ui.Thumbnail`, :class:`~.ui.Button`] + The accessory component displayed next to the section text. """ __repr_attributes__: ClassVar[Tuple[str, ...]] = ( - "_components", + "components", "accessory", ) @@ -47,10 +54,12 @@ def __init__( id: int = 0, ) -> None: self._id: int = id - self._components: List[TextDisplay] = [ + # this list can be modified without any runtime checks later on, + # just assume the user knows what they're doing at that point + self.components: List[TextDisplay] = [ ensure_ui_component(c, "components") for c in components ] - self._accessory: Union[Thumbnail, Button[Any]] = ensure_ui_component(accessory, "accessory") + self.accessory: Union[Thumbnail, Button[Any]] = ensure_ui_component(accessory, "accessory") # these are reimplemented here to store the value in a separate attribute, # since `Section` lazily constructs `_underlying`, unlike most components @@ -63,22 +72,11 @@ def id(self) -> int: def id(self, value: int) -> None: self._id = value - # TODO: consider moving runtime checks from constructor into property setters, also making these fields writable - @property - def components(self) -> Sequence[TextDisplay]: - """Sequence[:class:`~.ui.TextDisplay`]: A read-only proxy of the text items in this section.""" - return SequenceProxy(self._components) - - @property - def accessory(self) -> Union[Thumbnail, Button[Any]]: - """Union[:class:`~.ui.Thumbnail`, :class:`~.ui.Button`]: The accessory component displayed next to the section text.""" - return self._accessory - @property def _underlying(self) -> SectionComponent: return SectionComponent._raw_construct( type=ComponentType.section, id=self._id, - components=[comp._underlying for comp in self._components], - accessory=self._accessory._underlying, + components=[comp._underlying for comp in self.components], + accessory=self.accessory._underlying, ) From f1e86d34637204aa27f6fd26c1bf171e676b47a8 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Mon, 11 Aug 2025 19:36:38 +0200 Subject: [PATCH 068/104] feat: add `str()` cast to `TextDisplay.content`, matching embed behavior --- disnake/ui/text_display.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/disnake/ui/text_display.py b/disnake/ui/text_display.py index 33fc41bfd3..74c1a2b3eb 100644 --- a/disnake/ui/text_display.py +++ b/disnake/ui/text_display.py @@ -36,7 +36,7 @@ def __init__(self, content: str, *, id: int = 0) -> None: self._underlying = TextDisplayComponent._raw_construct( type=ComponentType.text_display, id=id, - content=content, + content=str(content), ) @property @@ -46,5 +46,4 @@ def content(self) -> str: @content.setter def content(self, value: str) -> None: - # TODO: consider str cast? - self._underlying.content = value + self._underlying.content = str(value) From 9b812f9baa44df7bfee515e64ea7a8170f39c03b Mon Sep 17 00:00:00 2001 From: shiftinv Date: Mon, 11 Aug 2025 19:45:35 +0200 Subject: [PATCH 069/104] chore: remove + update TODOs --- disnake/ui/_types.py | 2 -- tests/ui/test_action_row.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/disnake/ui/_types.py b/disnake/ui/_types.py index a5ee800603..a4513fa079 100644 --- a/disnake/ui/_types.py +++ b/disnake/ui/_types.py @@ -22,8 +22,6 @@ from .select import ChannelSelect, MentionableSelect, RoleSelect, StringSelect, UserSelect from .view import View -# TODO: consider if there are any useful types to make public (e.g. disnake-compass used MessageUIComponent) - V_co = TypeVar("V_co", bound="Optional[View]", covariant=True) AnySelect = Union[ diff --git a/tests/ui/test_action_row.py b/tests/ui/test_action_row.py index a96b5d4736..d995921abc 100644 --- a/tests/ui/test_action_row.py +++ b/tests/ui/test_action_row.py @@ -206,7 +206,7 @@ def _test_typing_init(self) -> None: # pragma: no cover assert_type(ActionRow(button1, select), ActionRow[ActionRowMessageComponent]) assert_type(ActionRow(select, button1), ActionRow[ActionRowMessageComponent]) - # TODO: no longer works since the overload changed for normalize_components. may revisit this. + # FIXME: no longer works since the overload changed for normalize_components. may revisit this. # # these should fail to type-check - if they pass, there will be an error # # because of the unnecessary ignore comment # ActionRow(button1, text_input) From 44bc2a9f45beda65a93431485a1759138301131a Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 13 Aug 2025 10:52:22 +0200 Subject: [PATCH 070/104] test: expand `normalize_components` test for v2 --- tests/ui/test_action_row.py | 29 +++++++++++++++++++++++++---- tests/ui/test_components.py | 2 +- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/tests/ui/test_action_row.py b/tests/ui/test_action_row.py index d995921abc..088ff30d34 100644 --- a/tests/ui/test_action_row.py +++ b/tests/ui/test_action_row.py @@ -10,6 +10,7 @@ from disnake.ui import ( ActionRow, Button, + Separator, StringSelect, TextInput, WrappedComponent, @@ -22,6 +23,7 @@ button3 = Button() select = StringSelect() text_input = TextInput(label="a", custom_id="b") +separator = Separator() class TestActionRow: @@ -217,9 +219,6 @@ def _test_typing_init(self) -> None: # pragma: no cover assert_type(ActionRow(text_input, select), ActionRow[ActionRowModalComponent]) # type: ignore -# TODO: expand tests to cover v2 components - - @pytest.mark.parametrize( ("value", "expected"), [ @@ -243,12 +242,34 @@ def _test_typing_init(self) -> None: # pragma: no cover ([select, button1, button2], [[select], [button1, button2]]), ], ) -def test_normalize_components(value, expected) -> None: +def test_normalize_components__actionrow(value, expected) -> None: rows = normalize_components(value) assert all(isinstance(row, ActionRow) for row in rows) assert [list(row.children) for row in rows] == expected +@pytest.mark.parametrize( + ("value", "expected"), + [ + # simple cases + ([separator], [separator]), + ([separator, ActionRow(button1)], [separator, [button1]]), + ([ActionRow(button1), separator], [[button1], separator]), + ([separator, ActionRow(button1), separator], [separator, [button1], separator]), + # flat list + ([button1, separator], [[button1], separator]), + ([separator, button1], [separator, [button1]]), + ( + [separator, button1, button2, separator, button3], + [separator, [button1, button2], separator, [button3]], + ), + ], +) +def test_normalize_components__v2(value, expected) -> None: + result = normalize_components(value) + assert [(list(c.children) if isinstance(c, ActionRow) else c) for c in result] == expected + + def test_normalize_components__invalid() -> None: for value in (42, [42], [ActionRow(), 42], iter([button1])): with pytest.raises(TypeError, match=r"`components` must be a"): diff --git a/tests/ui/test_components.py b/tests/ui/test_components.py index d57c86305f..7a2cf24d36 100644 --- a/tests/ui/test_components.py +++ b/tests/ui/test_components.py @@ -26,7 +26,7 @@ ui.TextDisplay(""), ui.Thumbnail(""), ui.MediaGallery(), - ui.File(""), + ui.File("attachment://x"), ui.Separator(), ui.Container(), ] From e3d66c9814e234d6c0a2a33f887ba95f39bc0303 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 13 Aug 2025 10:54:21 +0200 Subject: [PATCH 071/104] fix: duplicate call in `File.file` setter, oops --- disnake/ui/file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/disnake/ui/file.py b/disnake/ui/file.py index b3498b2f1c..0fcc330a36 100644 --- a/disnake/ui/file.py +++ b/disnake/ui/file.py @@ -68,7 +68,7 @@ def file(self, value: LocalMediaItemInput) -> None: file_media = handle_media_item_input(value) if not file_media.url.startswith("attachment://"): raise ValueError("File component does not support external media URLs") - self._underlying.file = handle_media_item_input(file_media) + self._underlying.file = file_media @property def spoiler(self) -> bool: From 79b0ef81cf7d4ede0186d36510fb788fb69e9093 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 13 Aug 2025 19:02:10 +0200 Subject: [PATCH 072/104] chore: move `MediaItemInput` type alias out of type-checking-only block --- disnake/components.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 3657a73f1b..25530542a1 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -69,9 +69,6 @@ UserSelectMenu as UserSelectMenuPayload, ) - LocalMediaItemInput = Union[str, "UnfurledMediaItem"] - MediaItemInput = Union[LocalMediaItemInput, "AssetMixin", "Attachment"] - __all__ = ( "Component", "ActionRow", @@ -97,6 +94,11 @@ "Container", ) +# miscellaneous components-related type aliases + +LocalMediaItemInput = Union[str, "UnfurledMediaItem"] +MediaItemInput = Union[LocalMediaItemInput, "AssetMixin", "Attachment"] + AnySelectMenu = Union[ "StringSelectMenu", "UserSelectMenu", From 9b0d216a1f5f9b251ee5b41cffe352913d21d3a2 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 13 Aug 2025 19:19:48 +0200 Subject: [PATCH 073/104] fix: clarify `ui.File` error regarding local files --- disnake/ui/file.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/disnake/ui/file.py b/disnake/ui/file.py index 0fcc330a36..8cd51a06f9 100644 --- a/disnake/ui/file.py +++ b/disnake/ui/file.py @@ -49,7 +49,9 @@ def __init__( ) -> None: file_media = handle_media_item_input(file) if not file_media.url.startswith("attachment://"): - raise ValueError("File component does not support external media URLs") + raise ValueError( + "File component only supports `attachment://` references, not external media URLs" + ) self._underlying = FileComponent._raw_construct( type=ComponentType.file, @@ -67,7 +69,9 @@ def file(self) -> UnfurledMediaItem: def file(self, value: LocalMediaItemInput) -> None: file_media = handle_media_item_input(value) if not file_media.url.startswith("attachment://"): - raise ValueError("File component does not support external media URLs") + raise ValueError( + "File component only supports `attachment://` references, not external media URLs" + ) self._underlying.file = file_media @property From ee83dc994d87f1627a861584e73072003d4a0d24 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 13 Aug 2025 19:23:17 +0200 Subject: [PATCH 074/104] fix(docs): add missing media attribute descriptions in `Thumbnail` + `File` --- disnake/components.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 25530542a1..e12e7d0911 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -1022,8 +1022,9 @@ class Thumbnail(Component): Attributes ---------- - media: Any - n/a + media: :class:`UnfurledMediaItem` + The media item to display. Can be an arbitrary URL or attachment + reference (``attachment://``). description: Optional[:class:`str`] The thumbnail's description ("alt text"), if any. spoiler: :class:`bool` @@ -1165,8 +1166,9 @@ class FileComponent(Component): Attributes ---------- - file: Any - n/a + file: :class:`UnfurledMediaItem` + The file to display. This **only** supports attachment references (i.e. + using the ``attachment://`` syntax), not arbitrary URLs. spoiler: :class:`bool` Whether the file is marked as a spoiler. Defaults to ``False``. """ From bf3f2ceb12098a033f1345fb36318e5e04a8b97c Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 13 Aug 2025 19:32:05 +0200 Subject: [PATCH 075/104] feat: add `File.name/.size`, provided by api --- disnake/components.py | 11 ++++++++++- disnake/types/components.py | 2 ++ disnake/ui/file.py | 16 +++++++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index e12e7d0911..fc28c2893f 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -1171,9 +1171,15 @@ class FileComponent(Component): using the ``attachment://`` syntax), not arbitrary URLs. spoiler: :class:`bool` Whether the file is marked as a spoiler. Defaults to ``False``. + name: Optional[:class:`str`] + The name of the file. + This is available in objects from the API, and ignored when sending. + size: Optional[:class:`int`] + The size of the file. + This is available in objects from the API, and ignored when sending. """ - __slots__: Tuple[str, ...] = ("file", "spoiler") + __slots__: Tuple[str, ...] = ("file", "spoiler", "name", "size") __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ @@ -1184,6 +1190,9 @@ def __init__(self, data: FileComponentPayload) -> None: self.file: UnfurledMediaItem = UnfurledMediaItem.from_dict(data["file"]) self.spoiler: bool = data.get("spoiler", False) + self.name: Optional[str] = data.get("name") + self.size: Optional[int] = data.get("size") + def to_dict(self) -> FileComponentPayload: return { "type": self.type.value, diff --git a/disnake/types/components.py b/disnake/types/components.py index bacc81d910..8e1826e23b 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -211,6 +211,8 @@ class FileComponent(_BaseComponent): type: Literal[13] file: UnfurledMediaItem # only supports `attachment://` urls spoiler: NotRequired[bool] + name: NotRequired[str] # only provided by api + size: NotRequired[int] # only provided by api class SeparatorComponent(_BaseComponent): diff --git a/disnake/ui/file.py b/disnake/ui/file.py index 8cd51a06f9..d56ab6b775 100644 --- a/disnake/ui/file.py +++ b/disnake/ui/file.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar, Tuple +from typing import TYPE_CHECKING, ClassVar, Optional, Tuple from ..components import FileComponent, UnfurledMediaItem, handle_media_item_input from ..enums import ComponentType @@ -82,3 +82,17 @@ def spoiler(self) -> bool: @spoiler.setter def spoiler(self, value: bool) -> None: self._underlying.spoiler = value + + @property + def name(self) -> Optional[str]: + """Optional[:class:`str`]: The name of the file. + This is available in objects from the API, and ignored when sending. + """ + return self._underlying.name + + @property + def size(self) -> Optional[int]: + """Optional[:class:`int`]: The size of the file. + This is available in objects from the API, and ignored when sending. + """ + return self._underlying.size From 4e66b187766fc9a45f6b1b65a2338982a933710e Mon Sep 17 00:00:00 2001 From: shiftinv Date: Tue, 19 Aug 2025 12:26:18 +0200 Subject: [PATCH 076/104] feat: add `ui.walk_components` --- disnake/components.py | 32 ------------------- disnake/interactions/message.py | 9 ++---- disnake/ui/action_row.py | 54 +++++++++++++++++++++++++++++++++ disnake/ui/view.py | 5 ++- docs/api/ui.rst | 2 ++ 5 files changed, 61 insertions(+), 41 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index fc28c2893f..49ff5dc2dc 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -9,13 +9,10 @@ Dict, Final, Generic, - Iterator, List, Literal, Mapping, Optional, - Sequence, - Set, Tuple, Type, TypeVar, @@ -1323,35 +1320,6 @@ def accent_color(self) -> Optional[Colour]: ) -def _walk_internal(component: Component, seen: Set[Component]) -> Iterator[Component]: - if component in seen: - # prevent infinite recursion if anyone manages to nest a component in itself - return - # add current component, while also creating a copy to allow reusing a component multiple times, - # as long as it's not within itself - seen = {*seen, component} - - yield component - - if isinstance(component, ActionRow): - for item in component.children: - yield from _walk_internal(item, seen) - elif isinstance(component, Section): - yield from _walk_internal(component.accessory, seen) - for item in component.components: - yield from _walk_internal(item, seen) - elif isinstance(component, Container): - for item in component.components: - yield from _walk_internal(item, seen) - - -# yields *all* components recursively -def _walk_all_components(components: Sequence[Component]) -> Iterator[Component]: - seen: Set[Component] = set() - for item in components: - yield from _walk_internal(item, seen) - - def handle_media_item_input(value: MediaItemInput) -> UnfurledMediaItem: if isinstance(value, UnfurledMediaItem): return value diff --git a/disnake/interactions/message.py b/disnake/interactions/message.py index 67512de8ea..548f5affab 100644 --- a/disnake/interactions/message.py +++ b/disnake/interactions/message.py @@ -4,13 +4,10 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union -from ..components import ( - VALID_ACTION_ROW_MESSAGE_COMPONENT_TYPES, - ActionRowMessageComponent, - _walk_all_components, -) +from ..components import VALID_ACTION_ROW_MESSAGE_COMPONENT_TYPES, ActionRowMessageComponent from ..enums import ComponentType, try_enum from ..message import Message +from ..ui.action_row import walk_components from ..utils import cached_slot_property from .base import ClientT, Interaction, InteractionDataResolved @@ -172,7 +169,7 @@ def resolved_values( def component(self) -> ActionRowMessageComponent: """Union[:class:`Button`, :class:`BaseSelectMenu`]: The component the user interacted with.""" # FIXME(3.0?): introduce common base type for components with `custom_id` - for component in _walk_all_components(self.message.components): + for component in walk_components(self.message.components): if ( isinstance(component, VALID_ACTION_ROW_MESSAGE_COMPONENT_TYPES) and component.custom_id == self.data.custom_id diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index 087d57b59a..d2149c7481 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -12,6 +12,7 @@ NoReturn, Optional, Sequence, + Set, Tuple, TypeVar, Union, @@ -25,8 +26,11 @@ ActionRowMessageComponent as ActionRowMessageComponentRaw, Button as ButtonComponent, ChannelSelectMenu as ChannelSelectComponent, + Component, + Container as ContainerComponent, MentionableSelectMenu as MentionableSelectComponent, RoleSelectMenu as RoleSelectComponent, + Section as SectionComponent, StringSelectMenu as StringSelectComponent, UserSelectMenu as UserSelectComponent, ) @@ -40,7 +44,9 @@ NonActionRowChildT, ) from .button import Button +from .container import Container from .item import UIComponent, WrappedComponent +from .section import Section from .select import ChannelSelect, MentionableSelect, RoleSelect, StringSelect, UserSelect from .text_input import TextInput @@ -68,6 +74,7 @@ "ModalUIComponent", "MessageActionRow", "ModalActionRow", + "walk_components", ) # FIXME(3.0): legacy @@ -1014,3 +1021,50 @@ def components_to_dict( cast("MessageTopLevelComponentPayload", c.to_component_dict()) for c in normalize_components(components) ] + + +ComponentT = TypeVar("ComponentT", Component, UIComponent) + + +def _walk_internal(component: ComponentT, seen: Set[ComponentT]) -> Iterator[ComponentT]: + if component in seen: + # prevent infinite recursion in case anyone manages to nest a component in itself + return + # add current component, while also creating a copy to allow reusing a component multiple times, + # as long as it's not within itself + seen = {*seen, component} + + yield component + + if isinstance(component, (ActionRowComponent, ActionRow)): + for item in component.children: + yield from _walk_internal(item, seen) + elif isinstance(component, (SectionComponent, Section)): + yield from _walk_internal(component.accessory, seen) + for item in component.components: + yield from _walk_internal(item, seen) # type: ignore # this is fine, pyright loses the conditional type when iterating + elif isinstance(component, (ContainerComponent, Container)): + for item in component.components: + yield from _walk_internal(item, seen) # type: ignore + + +def walk_components(components: Sequence[ComponentT]) -> Iterator[ComponentT]: + """Iterate over given components, yielding each individual component, + including child components where applicable (e.g. for :class:`ActionRow` and :class:`Container`). + + .. versionadded:: 2.11 + + Parameters + ---------- + components: Union[Sequence[:class:`~disnake.Component`], Sequence[:class:`UIComponent`]] + The sequence of components to iterate over. This supports both :class:`disnake.Component` + objects and :class:`.ui.UIComponent` objects. + + Yields + ------ + Union[:class:`~disnake.Component`, :class:`UIComponent`] + A component from the given sequence or child component thereof. + """ + seen: Set[ComponentT] = set() + for item in components: + yield from _walk_internal(item, seen) diff --git a/disnake/ui/view.py b/disnake/ui/view.py index 63c3ac4300..fe4e3b724c 100644 --- a/disnake/ui/view.py +++ b/disnake/ui/view.py @@ -27,10 +27,9 @@ ActionRowMessageComponent, Button as ButtonComponent, _component_factory, - _walk_all_components, ) from ..enums import try_enum_to_int -from .action_row import _message_component_to_item +from .action_row import _message_component_to_item, walk_components from .button import Button from .item import Item @@ -230,7 +229,7 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) """ view = View(timeout=timeout) # FIXME: preserve rows - for component in _walk_all_components(message.components): + for component in walk_components(message.components): if isinstance(component, ActionRowComponent): continue elif not isinstance(component, VALID_ACTION_ROW_MESSAGE_COMPONENT_TYPES): diff --git a/docs/api/ui.rst b/docs/api/ui.rst index e53355c0b1..2a77cf86c0 100644 --- a/docs/api/ui.rst +++ b/docs/api/ui.rst @@ -217,3 +217,5 @@ Functions .. autofunction:: user_select(cls=UserSelect, *, custom_id=..., placeholder=None, min_values=1, max_values=1, disabled=False, default_values=None, row=None) :decorator: + +.. autofunction:: walk_components From 2107734ef679f4e3a43d109e41735d199eabfbc5 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Tue, 19 Aug 2025 14:00:08 +0200 Subject: [PATCH 077/104] fix: include name+size in `FileComponent._raw_construct` call --- disnake/ui/file.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/disnake/ui/file.py b/disnake/ui/file.py index d56ab6b775..fb3eacd463 100644 --- a/disnake/ui/file.py +++ b/disnake/ui/file.py @@ -58,6 +58,8 @@ def __init__( id=id, file=file_media, spoiler=spoiler, + name=None, + size=None, ) @property From 4d3bc253e5ff30a1e23c85a625a3a9b784463d21 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Tue, 19 Aug 2025 14:26:30 +0200 Subject: [PATCH 078/104] feat: add public `components_from_message` method, and `from_component` on all `UIComponent`s --- disnake/ui/action_row.py | 122 ++++++++++++++++++++++++++++++------ disnake/ui/container.py | 18 +++++- disnake/ui/file.py | 19 ++++++ disnake/ui/item.py | 18 +++--- disnake/ui/media_gallery.py | 14 ++++- disnake/ui/section.py | 17 ++++- disnake/ui/separator.py | 13 +++- disnake/ui/text_display.py | 12 +++- disnake/ui/text_input.py | 19 +++++- disnake/ui/thumbnail.py | 12 ++++ docs/api/ui.rst | 2 + tests/ui/test_components.py | 5 +- 12 files changed, 233 insertions(+), 38 deletions(-) diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index d2149c7481..76b57f7ec2 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -9,11 +9,13 @@ Generic, Iterator, List, + Mapping, NoReturn, Optional, Sequence, Set, Tuple, + Type, TypeVar, Union, cast, @@ -28,10 +30,16 @@ ChannelSelectMenu as ChannelSelectComponent, Component, Container as ContainerComponent, + FileComponent as FileComponent, + MediaGallery as MediaGalleryComponent, MentionableSelectMenu as MentionableSelectComponent, RoleSelectMenu as RoleSelectComponent, Section as SectionComponent, + Separator as SeparatorComponent, StringSelectMenu as StringSelectComponent, + TextDisplay as TextDisplayComponent, + TextInput as TextInputComponent, + Thumbnail as ThumbnailComponent, UserSelectMenu as UserSelectComponent, ) from ..enums import ButtonStyle, ChannelType, ComponentType, TextInputStyle @@ -41,14 +49,20 @@ ActionRowMessageComponent, ActionRowModalComponent, ComponentInput, + MessageTopLevelComponent, NonActionRowChildT, ) from .button import Button from .container import Container +from .file import File from .item import UIComponent, WrappedComponent +from .media_gallery import MediaGallery from .section import Section from .select import ChannelSelect, MentionableSelect, RoleSelect, StringSelect, UserSelect +from .separator import Separator +from .text_display import TextDisplay from .text_input import TextInput +from .thumbnail import Thumbnail if TYPE_CHECKING: from typing_extensions import Self, TypeAlias @@ -75,6 +89,7 @@ "MessageActionRow", "ModalActionRow", "walk_components", + "components_from_message", ) # FIXME(3.0): legacy @@ -101,26 +116,6 @@ ) -def _message_component_to_item( - component: ActionRowMessageComponentRaw, -) -> Optional[ActionRowMessageComponent]: - if isinstance(component, ButtonComponent): - return Button.from_component(component) - if isinstance(component, StringSelectComponent): - return StringSelect.from_component(component) - if isinstance(component, UserSelectComponent): - return UserSelect.from_component(component) - if isinstance(component, RoleSelectComponent): - return RoleSelect.from_component(component) - if isinstance(component, MentionableSelectComponent): - return MentionableSelect.from_component(component) - if isinstance(component, ChannelSelectComponent): - return ChannelSelect.from_component(component) - - assert_never(component) - return None - - class ActionRow(UIComponent, Generic[ActionRowChildT]): """Represents a UI action row. Useful for lower level component manipulation. @@ -832,6 +827,16 @@ def _underlying(self) -> ActionRowComponent[ActionRowChildComponent]: def to_component_dict(self) -> ActionRowPayload: return self._underlying.to_dict() + @classmethod + def from_component(cls, action_row: ActionRowComponent) -> Self: + return cls( + *cast( + "List[ActionRowChildT]", + [_to_ui_component(c) for c in action_row.children], + ), + id=action_row.id, + ) + def __delitem__(self, index: Union[int, slice]) -> None: del self._children[index] @@ -1068,3 +1073,80 @@ def walk_components(components: Sequence[ComponentT]) -> Iterator[ComponentT]: seen: Set[ComponentT] = set() for item in components: yield from _walk_internal(item, seen) + + +def components_from_message(message: Message) -> List[MessageTopLevelComponent]: + """Create a list of :class:`UIComponent`\\s from the components of an existing message. + + This will abide by existing component format on the message, including component + ordering. Components will be transformed to UI kit components, such that + they can be easily modified and re-sent. + + .. versionadded:: 2.11 + + Parameters + ---------- + message: :class:`disnake.Message` + The message from which to extract the components. + + Raises + ------ + TypeError + An unknown component type is encountered. + + Returns + ------- + List[:class:`UIComponent`]: + The ui components parsed from the components on the message. + """ + components: List[UIComponent] = [_to_ui_component(c) for c in message.components] + return cast("List[MessageTopLevelComponent]", components) + + +UI_COMPONENT_LOOKUP: Mapping[Type[Component], Type[UIComponent]] = { + ActionRowComponent: ActionRow, + ButtonComponent: Button, + StringSelectComponent: StringSelect, + TextInputComponent: TextInput, + UserSelectComponent: UserSelect, + RoleSelectComponent: RoleSelect, + MentionableSelectComponent: MentionableSelect, + ChannelSelectComponent: ChannelSelect, + SectionComponent: Section, + TextDisplayComponent: TextDisplay, + ThumbnailComponent: Thumbnail, + MediaGalleryComponent: MediaGallery, + FileComponent: File, + SeparatorComponent: Separator, + ContainerComponent: Container, +} + + +def _to_ui_component(component: Component) -> UIComponent: + try: + ui_cls = UI_COMPONENT_LOOKUP[type(component)] + except KeyError: + # this should never happen + raise TypeError(f"unknown component type: {type(component)}") + else: + return ui_cls.from_component(component) # type: ignore + + +def _message_component_to_item( + component: ActionRowMessageComponentRaw, +) -> Optional[ActionRowMessageComponent]: + if isinstance( + component, + ( + ButtonComponent, + StringSelectComponent, + UserSelectComponent, + RoleSelectComponent, + MentionableSelectComponent, + ChannelSelectComponent, + ), + ): + return _to_ui_component(component) # type: ignore + + assert_never(component) + return None diff --git a/disnake/ui/container.py b/disnake/ui/container.py index 2c560e142b..5a83c45078 100644 --- a/disnake/ui/container.py +++ b/disnake/ui/container.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, ClassVar, List, Optional, Tuple, Union, cast from ..colour import Colour from ..components import Container as ContainerComponent @@ -11,6 +11,8 @@ from .item import UIComponent, ensure_ui_component if TYPE_CHECKING: + from typing_extensions import Self + from .action_row import ActionRow, ActionRowMessageComponent from .file import File from .media_gallery import MediaGallery @@ -104,3 +106,17 @@ def _underlying(self) -> ContainerComponent: _accent_colour=self.accent_colour.value if self.accent_colour is not None else None, spoiler=self.spoiler, ) + + @classmethod + def from_component(cls, container: ContainerComponent) -> Self: + from .action_row import _to_ui_component + + return cls( + *cast( + "List[ContainerChildUIComponent]", + [_to_ui_component(c) for c in container.components], + ), + accent_colour=container.accent_colour, + spoiler=container.spoiler, + id=container.id, + ) diff --git a/disnake/ui/file.py b/disnake/ui/file.py index fb3eacd463..14e816b00a 100644 --- a/disnake/ui/file.py +++ b/disnake/ui/file.py @@ -10,6 +10,8 @@ from .item import UIComponent if TYPE_CHECKING: + from typing_extensions import Self + from ..components import LocalMediaItemInput __all__ = ("File",) @@ -98,3 +100,20 @@ def size(self) -> Optional[int]: This is available in objects from the API, and ignored when sending. """ return self._underlying.size + + @classmethod + def from_component(cls, file: FileComponent) -> Self: + media = file.file + if not media.url.startswith("attachment://") and file.name: + # TODO: does this work correctly with special characters? + media = UnfurledMediaItem(f"attachment://{file.name}") + + self = cls( + file=media, + spoiler=file.spoiler, + id=file.id, + ) + # copy read-only fields + self._underlying.name = file.name + self._underlying.size = file.size + return self diff --git a/disnake/ui/item.py b/disnake/ui/item.py index 0d1a1b2eb2..36e91d441d 100644 --- a/disnake/ui/item.py +++ b/disnake/ui/item.py @@ -103,6 +103,10 @@ def id(self, value: int) -> None: def to_component_dict(self) -> Dict[str, Any]: return self._underlying.to_dict() + @classmethod + def from_component(cls, component: Component, /) -> Self: + return cls() + # Essentially the same as the base `UIComponent`, with the addition of `width`. class WrappedComponent(UIComponent): @@ -122,13 +126,13 @@ class WrappedComponent(UIComponent): """ # the purpose of these two is just more precise typechecking compared to the base type + if TYPE_CHECKING: - @property - @abstractmethod - def _underlying(self) -> ActionRowChildComponent: ... + @property + @abstractmethod + def _underlying(self) -> ActionRowChildComponent: ... - def to_component_dict(self) -> ActionRowChildComponentPayload: - return self._underlying.to_dict() + def to_component_dict(self) -> ActionRowChildComponentPayload: ... @property @abstractmethod @@ -175,10 +179,6 @@ def refresh_component(self, component: ActionRowChildComponent) -> None: def refresh_state(self, interaction: MessageInteraction) -> None: return None - @classmethod - def from_component(cls, component: ActionRowChildComponent) -> Self: - return cls() - def is_dispatchable(self) -> bool: return False diff --git a/disnake/ui/media_gallery.py b/disnake/ui/media_gallery.py index 252724f3f9..f6aeabdaca 100644 --- a/disnake/ui/media_gallery.py +++ b/disnake/ui/media_gallery.py @@ -2,13 +2,16 @@ from __future__ import annotations -from typing import ClassVar, List, Sequence, Tuple +from typing import TYPE_CHECKING, ClassVar, List, Sequence, Tuple from ..components import MediaGallery as MediaGalleryComponent, MediaGalleryItem from ..enums import ComponentType from ..utils import MISSING from .item import UIComponent +if TYPE_CHECKING: + from typing_extensions import Self + __all__ = ("MediaGallery",) @@ -46,3 +49,12 @@ def items(self) -> List[MediaGalleryItem]: @items.setter def items(self, values: Sequence[MediaGalleryItem]) -> None: self._underlying.items = list(values) + + @classmethod + def from_component(cls, media_gallery: MediaGalleryComponent) -> Self: + return cls( + # FIXME: this might not work with items created with `attachment://` + # XXX: consider copying items + *media_gallery.items, + id=media_gallery.id, + ) diff --git a/disnake/ui/section.py b/disnake/ui/section.py index 99f04a30fb..9f5c40b878 100644 --- a/disnake/ui/section.py +++ b/disnake/ui/section.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, ClassVar, List, Tuple, Union +from typing import TYPE_CHECKING, Any, ClassVar, List, Tuple, Union, cast from ..components import Section as SectionComponent from ..enums import ComponentType @@ -10,6 +10,8 @@ from .item import UIComponent, ensure_ui_component if TYPE_CHECKING: + from typing_extensions import Self + from .button import Button from .text_display import TextDisplay from .thumbnail import Thumbnail @@ -80,3 +82,16 @@ def _underlying(self) -> SectionComponent: components=[comp._underlying for comp in self.components], accessory=self.accessory._underlying, ) + + @classmethod + def from_component(cls, section: SectionComponent) -> Self: + from .action_row import _to_ui_component + + return cls( + *cast( + "List[TextDisplay]", + [_to_ui_component(c) for c in section.components], + ), + accessory=cast("Union[Thumbnail, Button[Any]]", _to_ui_component(section.accessory)), + id=section.id, + ) diff --git a/disnake/ui/separator.py b/disnake/ui/separator.py index a9b944fa20..779693531b 100644 --- a/disnake/ui/separator.py +++ b/disnake/ui/separator.py @@ -2,13 +2,16 @@ from __future__ import annotations -from typing import ClassVar, Tuple +from typing import TYPE_CHECKING, ClassVar, Tuple from ..components import Separator as SeparatorComponent from ..enums import ComponentType, SeparatorSpacingSize from ..utils import MISSING from .item import UIComponent +if TYPE_CHECKING: + from typing_extensions import Self + __all__ = ("Separator",) @@ -69,3 +72,11 @@ def spacing(self) -> SeparatorSpacingSize: @spacing.setter def spacing(self, value: SeparatorSpacingSize) -> None: self._underlying.spacing = value + + @classmethod + def from_component(cls, separator: SeparatorComponent) -> Self: + return cls( + divider=separator.divider, + spacing=separator.spacing, + id=separator.id, + ) diff --git a/disnake/ui/text_display.py b/disnake/ui/text_display.py index 74c1a2b3eb..60dc09bc28 100644 --- a/disnake/ui/text_display.py +++ b/disnake/ui/text_display.py @@ -2,13 +2,16 @@ from __future__ import annotations -from typing import ClassVar, Tuple +from typing import TYPE_CHECKING, ClassVar, Tuple from ..components import TextDisplay as TextDisplayComponent from ..enums import ComponentType from ..utils import MISSING from .item import UIComponent +if TYPE_CHECKING: + from typing_extensions import Self + __all__ = ("TextDisplay",) @@ -47,3 +50,10 @@ def content(self) -> str: @content.setter def content(self, value: str) -> None: self._underlying.content = str(value) + + @classmethod + def from_component(cls, text_display: TextDisplayComponent) -> Self: + return cls( + content=text_display.content, + id=text_display.id, + ) diff --git a/disnake/ui/text_input.py b/disnake/ui/text_input.py index 4cb70707aa..00029b95b7 100644 --- a/disnake/ui/text_input.py +++ b/disnake/ui/text_input.py @@ -2,13 +2,16 @@ from __future__ import annotations -from typing import ClassVar, Optional, Tuple +from typing import TYPE_CHECKING, ClassVar, Optional, Tuple from ..components import TextInput as TextInputComponent from ..enums import ComponentType, TextInputStyle from ..utils import MISSING from .item import WrappedComponent +if TYPE_CHECKING: + from typing_extensions import Self + __all__ = ("TextInput",) @@ -159,3 +162,17 @@ def max_length(self) -> Optional[int]: @max_length.setter def max_length(self, value: Optional[int]) -> None: self._underlying.max_length = value + + @classmethod + def from_component(cls, text_input: TextInputComponent) -> Self: + return cls( + label=text_input.label or "", + custom_id=text_input.custom_id, + style=text_input.style, + placeholder=text_input.placeholder, + value=text_input.value, + required=text_input.required, + min_length=text_input.min_length, + max_length=text_input.max_length, + id=text_input.id, + ) diff --git a/disnake/ui/thumbnail.py b/disnake/ui/thumbnail.py index 55dbfd7e11..002389b0ff 100644 --- a/disnake/ui/thumbnail.py +++ b/disnake/ui/thumbnail.py @@ -10,6 +10,8 @@ from .item import UIComponent if TYPE_CHECKING: + from typing_extensions import Self + from ..components import MediaItemInput __all__ = ("Thumbnail",) @@ -87,3 +89,13 @@ def spoiler(self) -> bool: @spoiler.setter def spoiler(self, value: bool) -> None: self._underlying.spoiler = value + + @classmethod + def from_component(cls, thumbnail: ThumbnailComponent) -> Self: + return cls( + # FIXME: this might not work with items created with `attachment://` + media=thumbnail.media, + description=thumbnail.description, + spoiler=thumbnail.spoiler, + id=thumbnail.id, + ) diff --git a/docs/api/ui.rst b/docs/api/ui.rst index 2a77cf86c0..3ba30a68e2 100644 --- a/docs/api/ui.rst +++ b/docs/api/ui.rst @@ -219,3 +219,5 @@ Functions :decorator: .. autofunction:: walk_components + +.. autofunction:: components_from_message diff --git a/tests/ui/test_components.py b/tests/ui/test_components.py index 7a2cf24d36..7bc2764292 100644 --- a/tests/ui/test_components.py +++ b/tests/ui/test_components.py @@ -45,6 +45,5 @@ def test_id_property(obj: ui.UIComponent) -> None: obj.id = 1234 assert obj.id == 1234 - if isinstance(obj, ui.Item): - obj2 = type(obj).from_component(obj._underlying) - assert obj2.id == 1234 + obj2 = type(obj).from_component(obj._underlying) + assert obj2.id == 1234 From 3fca8c2997587defaf6fa00e5956e3e8b03601f3 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Tue, 19 Aug 2025 14:30:39 +0200 Subject: [PATCH 079/104] docs: add links to v2 versions of `ActionRow.rows_from_message/.walk_components` --- disnake/ui/action_row.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index 76b57f7ec2..c39d14b06d 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -897,6 +897,10 @@ def rows_from_message( ordering and rows. Components will be transformed to UI kit components, such that they can be easily modified and re-sent as action rows. + .. note:: + This only supports :class:`ActionRow`\\s and associated components, i.e. no v2 components. + See :func:`.ui.components_from_message` for a function that supports all component types. + .. versionadded:: 2.6 Parameters @@ -941,6 +945,10 @@ def walk_components( """Iterate over the components in a sequence of action rows, yielding each individual component together with the action row of which it is a child. + .. note:: + This only supports :class:`ActionRow`\\s, i.e. no v2 components. + See :func:`.ui.walk_components` for a function that supports all component types. + .. versionadded:: 2.6 Parameters From 159a09f0e44bb7901e5f7f16931947a358599e14 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Tue, 19 Aug 2025 14:51:35 +0200 Subject: [PATCH 080/104] fix: retain other media fields in `ui.File.from_component` --- disnake/ui/file.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/disnake/ui/file.py b/disnake/ui/file.py index 14e816b00a..4ed1e7a476 100644 --- a/disnake/ui/file.py +++ b/disnake/ui/file.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy from typing import TYPE_CHECKING, ClassVar, Optional, Tuple from ..components import FileComponent, UnfurledMediaItem, handle_media_item_input @@ -105,8 +106,9 @@ def size(self) -> Optional[int]: def from_component(cls, file: FileComponent) -> Self: media = file.file if not media.url.startswith("attachment://") and file.name: - # TODO: does this work correctly with special characters? - media = UnfurledMediaItem(f"attachment://{file.name}") + # turn cdn url into `attachment://` url, retain other fields + media = copy.copy(media) + media.url = f"attachment://{file.name}" self = cls( file=media, From c83465d8556b3d7075791bba6ad984372feac661 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Tue, 19 Aug 2025 19:11:10 +0200 Subject: [PATCH 081/104] fix: attach state to `UnfurledMediaItem`s when creating components from message --- disnake/components.py | 92 +++++++++++++++++++++++++++++-------------- disnake/message.py | 6 +-- disnake/ui/view.py | 6 ++- 3 files changed, 71 insertions(+), 33 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 49ff5dc2dc..1b1b1b1406 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -39,6 +39,7 @@ from .emoji import Emoji from .message import Attachment + from .state import ConnectionState from .types.components import ( ActionRow as ActionRowPayload, AnySelectMenu as AnySelectMenuPayload, @@ -229,11 +230,11 @@ class ActionRow(Component, Generic[ActionRowChildComponentT]): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__(self, data: ActionRowPayload) -> None: + def __init__(self, data: ActionRowPayload, *, state: Optional[ConnectionState] = None) -> None: self.type: Literal[ComponentType.action_row] = ComponentType.action_row self.id = data.get("id", 0) - children = [_component_factory(d) for d in data.get("components", [])] + children = [_component_factory(d, state=state) for d in data.get("components", [])] self.children: List[ActionRowChildComponentT] = children # type: ignore def to_dict(self) -> ActionRowPayload: @@ -290,7 +291,9 @@ class Button(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__(self, data: ButtonComponentPayload) -> None: + def __init__( + self, data: ButtonComponentPayload, *, state: Optional[ConnectionState] = None + ) -> None: self.type: Literal[ComponentType.button] = ComponentType.button self.id = data.get("id", 0) @@ -302,6 +305,7 @@ def __init__(self, data: ButtonComponentPayload) -> None: self.emoji: Optional[PartialEmoji] try: self.emoji = PartialEmoji.from_dict(data["emoji"]) + self.emoji._state = state except KeyError: self.emoji = None @@ -388,7 +392,9 @@ class BaseSelectMenu(Component): # 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: + def __init__( + self, data: AnySelectMenuPayload, *, state: Optional[ConnectionState] = None + ) -> None: component_type = try_enum(ComponentType, data["type"]) self.type: SelectMenuType = component_type # type: ignore self.id = data.get("id", 0) @@ -456,8 +462,10 @@ class StringSelectMenu(BaseSelectMenu): __repr_info__: ClassVar[Tuple[str, ...]] = BaseSelectMenu.__repr_info__ + __slots__ type: Literal[ComponentType.string_select] - def __init__(self, data: StringSelectMenuPayload) -> None: - super().__init__(data) + def __init__( + self, data: StringSelectMenuPayload, *, state: Optional[ConnectionState] = None + ) -> None: + super().__init__(data, state=state) self.options: List[SelectOption] = [ SelectOption.from_dict(option) for option in data.get("options", []) ] @@ -629,8 +637,10 @@ class ChannelSelectMenu(BaseSelectMenu): __repr_info__: ClassVar[Tuple[str, ...]] = BaseSelectMenu.__repr_info__ + __slots__ type: Literal[ComponentType.channel_select] - def __init__(self, data: ChannelSelectMenuPayload) -> None: - super().__init__(data) + def __init__( + self, data: ChannelSelectMenuPayload, *, state: Optional[ConnectionState] = None + ) -> None: + super().__init__(data, state=state) # on the API side, an empty list is (currently) equivalent to no value channel_types = data.get("channel_types") self.channel_types: Optional[List[ChannelType]] = ( @@ -829,7 +839,7 @@ class TextInput(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__(self, data: TextInputPayload) -> None: + def __init__(self, data: TextInputPayload, *, state: Optional[ConnectionState] = None) -> None: self.type: Literal[ComponentType.text_input] = ComponentType.text_input self.id = data.get("id", 0) @@ -892,15 +902,18 @@ class Section(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__(self, data: SectionComponentPayload) -> None: + def __init__( + self, data: SectionComponentPayload, *, state: Optional[ConnectionState] = None + ) -> None: self.type: Literal[ComponentType.section] = ComponentType.section self.id = data.get("id", 0) - accessory = _component_factory(data["accessory"]) + accessory = _component_factory(data["accessory"], state=state) self.accessory: SectionAccessoryComponent = accessory # type: ignore self.components: List[SectionChildComponent] = [ - _component_factory(d, type=SectionChildComponent) for d in data.get("components", []) + _component_factory(d, state=state, type=SectionChildComponent) + for d in data.get("components", []) ] def to_dict(self) -> SectionComponentPayload: @@ -931,7 +944,9 @@ class TextDisplay(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__(self, data: TextDisplayComponentPayload) -> None: + def __init__( + self, data: TextDisplayComponentPayload, *, state: Optional[ConnectionState] = None + ) -> None: self.type: Literal[ComponentType.text_display] = ComponentType.text_display self.id = data.get("id", 0) @@ -987,15 +1002,16 @@ def __init__(self, url: str) -> None: self.content_type: Optional[str] = None self.attachment_id: Optional[int] = None - # FIXME: when deserializing, try to attach _state for AssetMixin as well @classmethod - def from_dict(cls, data: UnfurledMediaItemPayload) -> Self: + def from_dict(cls, data: UnfurledMediaItemPayload, *, state: Optional[ConnectionState]) -> Self: self = cls(data["url"]) self.proxy_url = data.get("proxy_url") self.height = _get_as_snowflake(data, "height") self.width = _get_as_snowflake(data, "width") self.content_type = data.get("content_type") self.attachment_id = _get_as_snowflake(data, "attachment_id") + # this may be missing, and is provided on a best-effort basis + self._state = state return self def to_dict(self) -> UnfurledMediaItemPayload: @@ -1036,11 +1052,13 @@ class Thumbnail(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__(self, data: ThumbnailComponentPayload) -> None: + def __init__( + self, data: ThumbnailComponentPayload, *, state: Optional[ConnectionState] = None + ) -> None: self.type: Literal[ComponentType.thumbnail] = ComponentType.thumbnail self.id = data.get("id", 0) - self.media: UnfurledMediaItem = UnfurledMediaItem.from_dict(data["media"]) + self.media: UnfurledMediaItem = UnfurledMediaItem.from_dict(data["media"], state=state) self.description: Optional[str] = data.get("description") self.spoiler: bool = data.get("spoiler", False) @@ -1079,11 +1097,15 @@ class MediaGallery(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__(self, data: MediaGalleryComponentPayload) -> None: + def __init__( + self, data: MediaGalleryComponentPayload, *, state: Optional[ConnectionState] = None + ) -> None: self.type: Literal[ComponentType.media_gallery] = ComponentType.media_gallery self.id = data.get("id", 0) - self.items: List[MediaGalleryItem] = [MediaGalleryItem.from_dict(i) for i in data["items"]] + self.items: List[MediaGalleryItem] = [ + MediaGalleryItem.from_dict(i, state=state) for i in data["items"] + ] def to_dict(self) -> MediaGalleryComponentPayload: return { @@ -1126,11 +1148,10 @@ def __init__( self.description: Optional[str] = description self.spoiler: bool = spoiler - # FIXME: when deserializing, try to attach _state for `self.media` as well @classmethod - def from_dict(cls, data: MediaGalleryItemPayload) -> Self: + def from_dict(cls, data: MediaGalleryItemPayload, *, state: Optional[ConnectionState]) -> Self: return cls( - media=UnfurledMediaItem.from_dict(data["media"]), + media=UnfurledMediaItem.from_dict(data["media"], state=state), description=data.get("description"), spoiler=data.get("spoiler", False), ) @@ -1180,11 +1201,13 @@ class FileComponent(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__(self, data: FileComponentPayload) -> None: + def __init__( + self, data: FileComponentPayload, *, state: Optional[ConnectionState] = None + ) -> None: self.type: Literal[ComponentType.file] = ComponentType.file self.id = data.get("id", 0) - self.file: UnfurledMediaItem = UnfurledMediaItem.from_dict(data["file"]) + self.file: UnfurledMediaItem = UnfurledMediaItem.from_dict(data["file"], state=state) self.spoiler: bool = data.get("spoiler", False) self.name: Optional[str] = data.get("name") @@ -1223,7 +1246,9 @@ class Separator(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__(self, data: SeparatorComponentPayload) -> None: + def __init__( + self, data: SeparatorComponentPayload, *, state: Optional[ConnectionState] = None + ) -> None: self.type: Literal[ComponentType.separator] = ComponentType.separator self.id = data.get("id", 0) @@ -1270,14 +1295,16 @@ class Container(Component): "components", ) - def __init__(self, data: ContainerComponentPayload) -> None: + def __init__( + self, data: ContainerComponentPayload, *, state: Optional[ConnectionState] = None + ) -> None: self.type: Literal[ComponentType.container] = ComponentType.container self.id = data.get("id", 0) self._accent_colour: Optional[int] = data.get("accent_color") self.spoiler: bool = data.get("spoiler", False) - components = [_component_factory(d) for d in data.get("components", [])] + components = [_component_factory(d, state=state) for d in data.get("components", [])] self.components: List[ContainerChildComponent] = components # type: ignore def to_dict(self) -> ContainerComponentPayload: @@ -1359,7 +1386,12 @@ def handle_media_item_input(value: MediaItemInput) -> UnfurledMediaItem: # NOTE: The type param is purely for type-checking, it has no implications on runtime behavior. -def _component_factory(data: ComponentPayload, *, type: Type[C] = Component) -> C: +def _component_factory( + data: ComponentPayload, + *, + state: Optional[ConnectionState], + type: Type[C] = Component, +) -> C: component_type = data["type"] try: @@ -1369,7 +1401,7 @@ def _component_factory(data: ComponentPayload, *, type: Type[C] = Component) -> as_enum = try_enum(ComponentType, component_type) return Component._raw_construct(type=as_enum) # type: ignore else: - return component_cls(data) # type: ignore + return component_cls(data, state=state) # type: ignore # this is just a rebranded _component_factory, as a workaround to Python not supporting typescript-like mapped types @@ -1377,6 +1409,8 @@ def _component_factory(data: ComponentPayload, *, type: Type[C] = Component) -> def _message_component_factory( data: MessageTopLevelComponentPayload, + *, + state: Optional[ConnectionState], ) -> MessageTopLevelComponent: ... else: diff --git a/disnake/message.py b/disnake/message.py index 558ef3862f..d21582c083 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -1192,7 +1192,7 @@ def __init__( StickerItem(data=d, state=state) for d in data.get("sticker_items", []) ] self.components: List[MessageTopLevelComponent] = [ - _message_component_factory(d) for d in data.get("components", []) + _message_component_factory(d, state=state) for d in data.get("components", []) ] self.poll: Optional[Poll] = None @@ -1439,7 +1439,7 @@ def _handle_mention_roles(self, role_mentions: List[int]) -> None: self.role_mentions.append(role) def _handle_components(self, components: List[MessageTopLevelComponentPayload]) -> None: - self.components = [_message_component_factory(d) for d in components] + self.components = [_message_component_factory(d, state=self._state) for d in components] def _rebind_cached_references(self, new_guild: Guild, new_channel: GuildMessageable) -> None: self.guild = new_guild @@ -2933,7 +2933,7 @@ def __init__( StickerItem(data=d, state=state) for d in data.get("sticker_items", []) ] self.components: List[MessageTopLevelComponent] = [ - _message_component_factory(d) for d in data.get("components", []) + _message_component_factory(d, state=state) for d in data.get("components", []) ] self.guild_id = guild_id diff --git a/disnake/ui/view.py b/disnake/ui/view.py index fe4e3b724c..2d6d038ef9 100644 --- a/disnake/ui/view.py +++ b/disnake/ui/view.py @@ -562,7 +562,11 @@ def update_from_message(self, message_id: int, components: Sequence[ComponentPay view = self._synced_message_views[message_id] rows = [ - _component_factory(d, type=ActionRowComponent[ActionRowMessageComponent]) + _component_factory( + d, + state=self._state, + type=ActionRowComponent[ActionRowMessageComponent], + ) for d in components ] for row in rows: From a385833823fedb3c3c1f44e40fbda003e18e6517 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Tue, 19 Aug 2025 19:13:25 +0200 Subject: [PATCH 082/104] sec: don't pass `state` for the time being `AssetMixin` uses its `url` field, which is fine for its original intended use, fetching CDN assets. `UnfurledMediaItem`, on the other hand, can be a resource from any arbitrary source, and only its `proxy_url` should be used for this case, since we don't want to hit foreign websites directly. --- disnake/components.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/disnake/components.py b/disnake/components.py index 1b1b1b1406..9f215f9b1a 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -1401,7 +1401,8 @@ def _component_factory( as_enum = try_enum(ComponentType, component_type) return Component._raw_construct(type=as_enum) # type: ignore else: - return component_cls(data, state=state) # type: ignore + # FIXME: pass state=state once `AssetMixin` can use `proxy_url` + return component_cls(data, state=None) # type: ignore # this is just a rebranded _component_factory, as a workaround to Python not supporting typescript-like mapped types From 56d495fa87575b1f28f4e14bd32daf3e00747743 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Tue, 19 Aug 2025 21:53:07 +0200 Subject: [PATCH 083/104] fix: attach state to media again, fetch from `proxy_url` instead --- disnake/asset.py | 56 +++++++++++++++++++++++++++---------------- disnake/components.py | 7 +++--- disnake/http.py | 5 +++- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/disnake/asset.py b/disnake/asset.py index 30fdd31aab..2a469e62f1 100644 --- a/disnake/asset.py +++ b/disnake/asset.py @@ -4,7 +4,7 @@ import io import os -from typing import TYPE_CHECKING, Any, Literal, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional, Tuple, Union import yarl @@ -33,47 +33,59 @@ MISSING = utils.MISSING -class AssetMixin: - url: str +class ResourceMixin: + _resource_url_attr: ClassVar[str] + _state: Optional[AnyState] __slots__: Tuple[str, ...] = ("_state",) + def __init_subclass__(cls, url_attr: Optional[str] = None) -> None: + # either this subclass has a `url_attr`, or a parent class already should + if url_attr: + cls._resource_url_attr = url_attr + elif not hasattr(cls, "_resource_url_attr"): + raise RuntimeError("ResourceMixin subclass must specify `url_attr`") + + @property + def _resource_url(self) -> str: + return getattr(self, self._resource_url_attr) + async def read(self) -> bytes: """|coro| - Retrieves the content of this asset as a :class:`bytes` object. + Retrieves the content of this resource as a :class:`bytes` object. Raises ------ DiscordException There was no internal connection state. HTTPException - Downloading the asset failed. + Downloading the resource failed. NotFound - The asset was deleted. + The resource was deleted. Returns ------- :class:`bytes` - The content of the asset. + The content of the resource. """ if self._state is None: raise DiscordException("Invalid state (no ConnectionState provided)") - return await self._state.http.get_from_cdn(self.url) + return await self._state.http.get_from_cdn(self._resource_url) async def save( self, fp: Union[str, bytes, os.PathLike, io.BufferedIOBase], *, seek_begin: bool = True ) -> int: """|coro| - Saves this asset into a file-like object. + Saves this resource into a file-like object. Parameters ---------- fp: Union[:class:`io.BufferedIOBase`, :class:`os.PathLike`] - The file-like object to save this asset to or the filename + The file-like object to save this resource to or the filename to use. If a filename is passed then a file is created with that filename and used instead. seek_begin: :class:`bool` @@ -85,9 +97,9 @@ async def save( DiscordException There was no internal connection state. HTTPException - Downloading the asset failed. + Downloading the resource failed. NotFound - The asset was deleted. + The resource was deleted. Returns ------- @@ -113,7 +125,7 @@ async def to_file( ) -> File: """|coro| - Converts the asset into a :class:`File` suitable for sending via + Converts the resource into a :class:`File` suitable for sending via :meth:`abc.Messageable.send`. .. versionadded:: 2.5 @@ -127,30 +139,30 @@ async def to_file( Whether the file is a spoiler. filename: Optional[:class:`str`] The filename to display when uploading to Discord. If this is not given, it defaults to - the name of the asset's URL. + the name of the resource's URL. description: Optional[:class:`str`] The file's description. Raises ------ DiscordException - The asset does not have an associated state. + The resource does not have an associated state. HTTPException - Downloading the asset failed. + Downloading the resource failed. NotFound - The asset was deleted. + The resource was deleted. TypeError - The asset is a unicode emoji or a sticker with lottie type. + The resource is a unicode emoji or a sticker with lottie type. Returns ------- :class:`File` - The asset as a file suitable for sending. + The resource as a file suitable for sending. """ data = await self.read() if not filename: - filename = yarl.URL(self.url).name + filename = yarl.URL(self._resource_url).name # if the filename doesn't have an extension (e.g. widget member avatars), # try to infer it from the data if not os.path.splitext(filename)[1]: @@ -161,6 +173,10 @@ async def to_file( return File(io.BytesIO(data), filename=filename, spoiler=spoiler, description=description) +class AssetMixin(ResourceMixin, url_attr="url"): + url: str + + class Asset(AssetMixin): """Represents a CDN asset on Discord. diff --git a/disnake/components.py b/disnake/components.py index 9f215f9b1a..b0c44a3c8e 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -20,7 +20,7 @@ cast, ) -from .asset import AssetMixin +from .asset import AssetMixin, ResourceMixin from .colour import Colour from .enums import ( ButtonStyle, @@ -960,7 +960,7 @@ def to_dict(self) -> TextDisplayComponentPayload: } -class UnfurledMediaItem(AssetMixin): +class UnfurledMediaItem(ResourceMixin, url_attr="proxy_url"): """Represents an unfurled/resolved media item within a component. .. versionadded:: 2.11 @@ -1401,8 +1401,7 @@ def _component_factory( as_enum = try_enum(ComponentType, component_type) return Component._raw_construct(type=as_enum) # type: ignore else: - # FIXME: pass state=state once `AssetMixin` can use `proxy_url` - return component_cls(data, state=None) # type: ignore + return component_cls(data, state=state) # type: ignore # this is just a rebranded _component_factory, as a workaround to Python not supporting typescript-like mapped types diff --git a/disnake/http.py b/disnake/http.py index b258985016..17ed978273 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -438,7 +438,10 @@ async def request( raise RuntimeError("Unreachable code in HTTP handling") async def get_from_cdn(self, url: str) -> bytes: - async with self.__session.get(url) as resp: + # `encoded=True` to prevent aiohttp from canonicalizing URLs and unescaping characters, + # which can break some urls (e.g. signed image proxy urls in components) + # https://github.com/aio-libs/aiohttp/issues/7393 + async with self.__session.get(yarl.URL(url, encoded=True)) as resp: if resp.status == 200: return await resp.read() elif resp.status == 404: From 568809df72b511160656d2e8d8cd6700e568f5a0 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Tue, 19 Aug 2025 21:59:52 +0200 Subject: [PATCH 084/104] revert: remove all `AssetMixin` stuff from `UnfurledMediaItem` This reverts the 3 previous commits, 56d495fa + a3858338 + c83465d8, and removes the `AssetMixin` parent class from `UnfurledMediaItem`. While the changes were working fine, the fact that we're handling `url` and `proxy_url` in the first place had been somewhat bugging me from the start, and the `yarl.URL(url, encoded=True)` thing required to make some `proxy_url`s work just didn't sit right with me, as URL canonicalization is a can of worms I'd rather not open. For now, things are perfectly fine as they are, and we can always come back to this at some point if desired. --- disnake/asset.py | 56 +++++++++----------------- disnake/components.py | 94 +++++++++++++------------------------------ disnake/http.py | 5 +-- disnake/message.py | 6 +-- disnake/ui/view.py | 6 +-- 5 files changed, 54 insertions(+), 113 deletions(-) diff --git a/disnake/asset.py b/disnake/asset.py index 2a469e62f1..30fdd31aab 100644 --- a/disnake/asset.py +++ b/disnake/asset.py @@ -4,7 +4,7 @@ import io import os -from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Literal, Optional, Tuple, Union import yarl @@ -33,59 +33,47 @@ MISSING = utils.MISSING -class ResourceMixin: - _resource_url_attr: ClassVar[str] - +class AssetMixin: + url: str _state: Optional[AnyState] __slots__: Tuple[str, ...] = ("_state",) - def __init_subclass__(cls, url_attr: Optional[str] = None) -> None: - # either this subclass has a `url_attr`, or a parent class already should - if url_attr: - cls._resource_url_attr = url_attr - elif not hasattr(cls, "_resource_url_attr"): - raise RuntimeError("ResourceMixin subclass must specify `url_attr`") - - @property - def _resource_url(self) -> str: - return getattr(self, self._resource_url_attr) - async def read(self) -> bytes: """|coro| - Retrieves the content of this resource as a :class:`bytes` object. + Retrieves the content of this asset as a :class:`bytes` object. Raises ------ DiscordException There was no internal connection state. HTTPException - Downloading the resource failed. + Downloading the asset failed. NotFound - The resource was deleted. + The asset was deleted. Returns ------- :class:`bytes` - The content of the resource. + The content of the asset. """ if self._state is None: raise DiscordException("Invalid state (no ConnectionState provided)") - return await self._state.http.get_from_cdn(self._resource_url) + return await self._state.http.get_from_cdn(self.url) async def save( self, fp: Union[str, bytes, os.PathLike, io.BufferedIOBase], *, seek_begin: bool = True ) -> int: """|coro| - Saves this resource into a file-like object. + Saves this asset into a file-like object. Parameters ---------- fp: Union[:class:`io.BufferedIOBase`, :class:`os.PathLike`] - The file-like object to save this resource to or the filename + The file-like object to save this asset to or the filename to use. If a filename is passed then a file is created with that filename and used instead. seek_begin: :class:`bool` @@ -97,9 +85,9 @@ async def save( DiscordException There was no internal connection state. HTTPException - Downloading the resource failed. + Downloading the asset failed. NotFound - The resource was deleted. + The asset was deleted. Returns ------- @@ -125,7 +113,7 @@ async def to_file( ) -> File: """|coro| - Converts the resource into a :class:`File` suitable for sending via + Converts the asset into a :class:`File` suitable for sending via :meth:`abc.Messageable.send`. .. versionadded:: 2.5 @@ -139,30 +127,30 @@ async def to_file( Whether the file is a spoiler. filename: Optional[:class:`str`] The filename to display when uploading to Discord. If this is not given, it defaults to - the name of the resource's URL. + the name of the asset's URL. description: Optional[:class:`str`] The file's description. Raises ------ DiscordException - The resource does not have an associated state. + The asset does not have an associated state. HTTPException - Downloading the resource failed. + Downloading the asset failed. NotFound - The resource was deleted. + The asset was deleted. TypeError - The resource is a unicode emoji or a sticker with lottie type. + The asset is a unicode emoji or a sticker with lottie type. Returns ------- :class:`File` - The resource as a file suitable for sending. + The asset as a file suitable for sending. """ data = await self.read() if not filename: - filename = yarl.URL(self._resource_url).name + filename = yarl.URL(self.url).name # if the filename doesn't have an extension (e.g. widget member avatars), # try to infer it from the data if not os.path.splitext(filename)[1]: @@ -173,10 +161,6 @@ async def to_file( return File(io.BytesIO(data), filename=filename, spoiler=spoiler, description=description) -class AssetMixin(ResourceMixin, url_attr="url"): - url: str - - class Asset(AssetMixin): """Represents a CDN asset on Discord. diff --git a/disnake/components.py b/disnake/components.py index b0c44a3c8e..78749724a5 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -20,7 +20,7 @@ cast, ) -from .asset import AssetMixin, ResourceMixin +from .asset import AssetMixin from .colour import Colour from .enums import ( ButtonStyle, @@ -39,7 +39,6 @@ from .emoji import Emoji from .message import Attachment - from .state import ConnectionState from .types.components import ( ActionRow as ActionRowPayload, AnySelectMenu as AnySelectMenuPayload, @@ -230,11 +229,11 @@ class ActionRow(Component, Generic[ActionRowChildComponentT]): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__(self, data: ActionRowPayload, *, state: Optional[ConnectionState] = None) -> None: + def __init__(self, data: ActionRowPayload) -> None: self.type: Literal[ComponentType.action_row] = ComponentType.action_row self.id = data.get("id", 0) - children = [_component_factory(d, state=state) for d in data.get("components", [])] + children = [_component_factory(d) for d in data.get("components", [])] self.children: List[ActionRowChildComponentT] = children # type: ignore def to_dict(self) -> ActionRowPayload: @@ -291,9 +290,7 @@ class Button(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__( - self, data: ButtonComponentPayload, *, state: Optional[ConnectionState] = None - ) -> None: + def __init__(self, data: ButtonComponentPayload) -> None: self.type: Literal[ComponentType.button] = ComponentType.button self.id = data.get("id", 0) @@ -305,7 +302,6 @@ def __init__( self.emoji: Optional[PartialEmoji] try: self.emoji = PartialEmoji.from_dict(data["emoji"]) - self.emoji._state = state except KeyError: self.emoji = None @@ -392,9 +388,7 @@ class BaseSelectMenu(Component): # 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, *, state: Optional[ConnectionState] = None - ) -> None: + def __init__(self, data: AnySelectMenuPayload) -> None: component_type = try_enum(ComponentType, data["type"]) self.type: SelectMenuType = component_type # type: ignore self.id = data.get("id", 0) @@ -462,10 +456,8 @@ class StringSelectMenu(BaseSelectMenu): __repr_info__: ClassVar[Tuple[str, ...]] = BaseSelectMenu.__repr_info__ + __slots__ type: Literal[ComponentType.string_select] - def __init__( - self, data: StringSelectMenuPayload, *, state: Optional[ConnectionState] = None - ) -> None: - super().__init__(data, state=state) + def __init__(self, data: StringSelectMenuPayload) -> None: + super().__init__(data) self.options: List[SelectOption] = [ SelectOption.from_dict(option) for option in data.get("options", []) ] @@ -637,10 +629,8 @@ class ChannelSelectMenu(BaseSelectMenu): __repr_info__: ClassVar[Tuple[str, ...]] = BaseSelectMenu.__repr_info__ + __slots__ type: Literal[ComponentType.channel_select] - def __init__( - self, data: ChannelSelectMenuPayload, *, state: Optional[ConnectionState] = None - ) -> None: - super().__init__(data, state=state) + def __init__(self, data: ChannelSelectMenuPayload) -> None: + super().__init__(data) # on the API side, an empty list is (currently) equivalent to no value channel_types = data.get("channel_types") self.channel_types: Optional[List[ChannelType]] = ( @@ -839,7 +829,7 @@ class TextInput(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__(self, data: TextInputPayload, *, state: Optional[ConnectionState] = None) -> None: + def __init__(self, data: TextInputPayload) -> None: self.type: Literal[ComponentType.text_input] = ComponentType.text_input self.id = data.get("id", 0) @@ -902,18 +892,15 @@ class Section(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__( - self, data: SectionComponentPayload, *, state: Optional[ConnectionState] = None - ) -> None: + def __init__(self, data: SectionComponentPayload) -> None: self.type: Literal[ComponentType.section] = ComponentType.section self.id = data.get("id", 0) - accessory = _component_factory(data["accessory"], state=state) + accessory = _component_factory(data["accessory"]) self.accessory: SectionAccessoryComponent = accessory # type: ignore self.components: List[SectionChildComponent] = [ - _component_factory(d, state=state, type=SectionChildComponent) - for d in data.get("components", []) + _component_factory(d, type=SectionChildComponent) for d in data.get("components", []) ] def to_dict(self) -> SectionComponentPayload: @@ -944,9 +931,7 @@ class TextDisplay(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__( - self, data: TextDisplayComponentPayload, *, state: Optional[ConnectionState] = None - ) -> None: + def __init__(self, data: TextDisplayComponentPayload) -> None: self.type: Literal[ComponentType.text_display] = ComponentType.text_display self.id = data.get("id", 0) @@ -960,7 +945,7 @@ def to_dict(self) -> TextDisplayComponentPayload: } -class UnfurledMediaItem(ResourceMixin, url_attr="proxy_url"): +class UnfurledMediaItem: """Represents an unfurled/resolved media item within a component. .. versionadded:: 2.11 @@ -1003,15 +988,13 @@ def __init__(self, url: str) -> None: self.attachment_id: Optional[int] = None @classmethod - def from_dict(cls, data: UnfurledMediaItemPayload, *, state: Optional[ConnectionState]) -> Self: + def from_dict(cls, data: UnfurledMediaItemPayload) -> Self: self = cls(data["url"]) self.proxy_url = data.get("proxy_url") self.height = _get_as_snowflake(data, "height") self.width = _get_as_snowflake(data, "width") self.content_type = data.get("content_type") self.attachment_id = _get_as_snowflake(data, "attachment_id") - # this may be missing, and is provided on a best-effort basis - self._state = state return self def to_dict(self) -> UnfurledMediaItemPayload: @@ -1052,13 +1035,11 @@ class Thumbnail(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__( - self, data: ThumbnailComponentPayload, *, state: Optional[ConnectionState] = None - ) -> None: + def __init__(self, data: ThumbnailComponentPayload) -> None: self.type: Literal[ComponentType.thumbnail] = ComponentType.thumbnail self.id = data.get("id", 0) - self.media: UnfurledMediaItem = UnfurledMediaItem.from_dict(data["media"], state=state) + self.media: UnfurledMediaItem = UnfurledMediaItem.from_dict(data["media"]) self.description: Optional[str] = data.get("description") self.spoiler: bool = data.get("spoiler", False) @@ -1097,15 +1078,11 @@ class MediaGallery(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__( - self, data: MediaGalleryComponentPayload, *, state: Optional[ConnectionState] = None - ) -> None: + def __init__(self, data: MediaGalleryComponentPayload) -> None: self.type: Literal[ComponentType.media_gallery] = ComponentType.media_gallery self.id = data.get("id", 0) - self.items: List[MediaGalleryItem] = [ - MediaGalleryItem.from_dict(i, state=state) for i in data["items"] - ] + self.items: List[MediaGalleryItem] = [MediaGalleryItem.from_dict(i) for i in data["items"]] def to_dict(self) -> MediaGalleryComponentPayload: return { @@ -1149,9 +1126,9 @@ def __init__( self.spoiler: bool = spoiler @classmethod - def from_dict(cls, data: MediaGalleryItemPayload, *, state: Optional[ConnectionState]) -> Self: + def from_dict(cls, data: MediaGalleryItemPayload) -> Self: return cls( - media=UnfurledMediaItem.from_dict(data["media"], state=state), + media=UnfurledMediaItem.from_dict(data["media"]), description=data.get("description"), spoiler=data.get("spoiler", False), ) @@ -1201,13 +1178,11 @@ class FileComponent(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__( - self, data: FileComponentPayload, *, state: Optional[ConnectionState] = None - ) -> None: + def __init__(self, data: FileComponentPayload) -> None: self.type: Literal[ComponentType.file] = ComponentType.file self.id = data.get("id", 0) - self.file: UnfurledMediaItem = UnfurledMediaItem.from_dict(data["file"], state=state) + self.file: UnfurledMediaItem = UnfurledMediaItem.from_dict(data["file"]) self.spoiler: bool = data.get("spoiler", False) self.name: Optional[str] = data.get("name") @@ -1246,9 +1221,7 @@ class Separator(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ - def __init__( - self, data: SeparatorComponentPayload, *, state: Optional[ConnectionState] = None - ) -> None: + def __init__(self, data: SeparatorComponentPayload) -> None: self.type: Literal[ComponentType.separator] = ComponentType.separator self.id = data.get("id", 0) @@ -1295,16 +1268,14 @@ class Container(Component): "components", ) - def __init__( - self, data: ContainerComponentPayload, *, state: Optional[ConnectionState] = None - ) -> None: + def __init__(self, data: ContainerComponentPayload) -> None: self.type: Literal[ComponentType.container] = ComponentType.container self.id = data.get("id", 0) self._accent_colour: Optional[int] = data.get("accent_color") self.spoiler: bool = data.get("spoiler", False) - components = [_component_factory(d, state=state) for d in data.get("components", [])] + components = [_component_factory(d) for d in data.get("components", [])] self.components: List[ContainerChildComponent] = components # type: ignore def to_dict(self) -> ContainerComponentPayload: @@ -1386,12 +1357,7 @@ def handle_media_item_input(value: MediaItemInput) -> UnfurledMediaItem: # NOTE: The type param is purely for type-checking, it has no implications on runtime behavior. -def _component_factory( - data: ComponentPayload, - *, - state: Optional[ConnectionState], - type: Type[C] = Component, -) -> C: +def _component_factory(data: ComponentPayload, *, type: Type[C] = Component) -> C: component_type = data["type"] try: @@ -1401,7 +1367,7 @@ def _component_factory( as_enum = try_enum(ComponentType, component_type) return Component._raw_construct(type=as_enum) # type: ignore else: - return component_cls(data, state=state) # type: ignore + return component_cls(data) # type: ignore # this is just a rebranded _component_factory, as a workaround to Python not supporting typescript-like mapped types @@ -1409,8 +1375,6 @@ def _component_factory( def _message_component_factory( data: MessageTopLevelComponentPayload, - *, - state: Optional[ConnectionState], ) -> MessageTopLevelComponent: ... else: diff --git a/disnake/http.py b/disnake/http.py index 17ed978273..b258985016 100644 --- a/disnake/http.py +++ b/disnake/http.py @@ -438,10 +438,7 @@ async def request( raise RuntimeError("Unreachable code in HTTP handling") async def get_from_cdn(self, url: str) -> bytes: - # `encoded=True` to prevent aiohttp from canonicalizing URLs and unescaping characters, - # which can break some urls (e.g. signed image proxy urls in components) - # https://github.com/aio-libs/aiohttp/issues/7393 - async with self.__session.get(yarl.URL(url, encoded=True)) as resp: + async with self.__session.get(url) as resp: if resp.status == 200: return await resp.read() elif resp.status == 404: diff --git a/disnake/message.py b/disnake/message.py index d21582c083..558ef3862f 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -1192,7 +1192,7 @@ def __init__( StickerItem(data=d, state=state) for d in data.get("sticker_items", []) ] self.components: List[MessageTopLevelComponent] = [ - _message_component_factory(d, state=state) for d in data.get("components", []) + _message_component_factory(d) for d in data.get("components", []) ] self.poll: Optional[Poll] = None @@ -1439,7 +1439,7 @@ def _handle_mention_roles(self, role_mentions: List[int]) -> None: self.role_mentions.append(role) def _handle_components(self, components: List[MessageTopLevelComponentPayload]) -> None: - self.components = [_message_component_factory(d, state=self._state) for d in components] + self.components = [_message_component_factory(d) for d in components] def _rebind_cached_references(self, new_guild: Guild, new_channel: GuildMessageable) -> None: self.guild = new_guild @@ -2933,7 +2933,7 @@ def __init__( StickerItem(data=d, state=state) for d in data.get("sticker_items", []) ] self.components: List[MessageTopLevelComponent] = [ - _message_component_factory(d, state=state) for d in data.get("components", []) + _message_component_factory(d) for d in data.get("components", []) ] self.guild_id = guild_id diff --git a/disnake/ui/view.py b/disnake/ui/view.py index 2d6d038ef9..fe4e3b724c 100644 --- a/disnake/ui/view.py +++ b/disnake/ui/view.py @@ -562,11 +562,7 @@ def update_from_message(self, message_id: int, components: Sequence[ComponentPay view = self._synced_message_views[message_id] rows = [ - _component_factory( - d, - state=self._state, - type=ActionRowComponent[ActionRowMessageComponent], - ) + _component_factory(d, type=ActionRowComponent[ActionRowMessageComponent]) for d in components ] for row in rows: From 79b58bbcd2294dbc1cbef3a1c8a1dd0ee42c01b9 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 20 Aug 2025 15:58:14 +0200 Subject: [PATCH 085/104] feat: add `ui.Container.accent_color` alias --- disnake/components.py | 31 ++++++++++++------------------- disnake/ui/container.py | 24 ++++++++++++++++++++---- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 78749724a5..bd63688589 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -1250,29 +1250,29 @@ class Container(Component): Attributes ---------- - spoiler: :class:`bool` - Whether the container is marked as a spoiler. Defaults to ``False``. components: List[Union[:class:`ActionRow`, :class:`Section`, :class:`TextDisplay`, :class:`MediaGallery`, :class:`FileComponent`, :class:`Separator`]] The components in this container. + accent_colour: Optional[:class:`Colour`] + The accent colour of the container. An alias exists under ``accent_color``. + spoiler: :class:`bool` + Whether the container is marked as a spoiler. Defaults to ``False``. """ __slots__: Tuple[str, ...] = ( - "_accent_colour", - "spoiler", - "components", - ) - - __repr_info__: ClassVar[Tuple[str, ...]] = ( "accent_colour", "spoiler", "components", ) + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + def __init__(self, data: ContainerComponentPayload) -> None: self.type: Literal[ComponentType.container] = ComponentType.container self.id = data.get("id", 0) - self._accent_colour: Optional[int] = data.get("accent_color") + self.accent_colour: Optional[Colour] = ( + Colour(accent_color) if (accent_color := data.get("accent_color")) is not None else None + ) self.spoiler: bool = data.get("spoiler", False) components = [_component_factory(d) for d in data.get("components", [])] @@ -1286,21 +1286,14 @@ def to_dict(self) -> ContainerComponentPayload: "components": [child.to_dict() for child in self.components], } - if self._accent_colour is not None: - payload["accent_color"] = self._accent_colour + if self.accent_colour is not None: + payload["accent_color"] = self.accent_colour.value return payload - @property - def accent_colour(self) -> Optional[Colour]: - """Optional[:class:`Colour`]: Returns the accent colour of the container. - An alias exists under ``accent_color``. - """ - return Colour(self._accent_colour) if self._accent_colour is not None else None - @property def accent_color(self) -> Optional[Colour]: - """Optional[:class:`Colour`]: Returns the accent color of the container. + """Optional[:class:`Colour`]: The accent color of the container. An alias exists under ``accent_colour``. """ return self.accent_colour diff --git a/disnake/ui/container.py b/disnake/ui/container.py index 5a83c45078..eec24cc5fd 100644 --- a/disnake/ui/container.py +++ b/disnake/ui/container.py @@ -58,6 +58,7 @@ class Container(UIComponent): The list of components in this container. accent_colour: Optional[:class:`.Colour`] The accent colour of the container. + An alias exists under ``accent_color``. spoiler: :class:`bool` Whether the container is marked as a spoiler. """ @@ -68,7 +69,6 @@ class Container(UIComponent): "spoiler", ) - # TODO: consider providing sequence operations (append, insert, remove, etc.) def __init__( self, *components: ContainerChildUIComponent, @@ -82,8 +82,7 @@ def __init__( self.components: List[ContainerChildUIComponent] = [ ensure_ui_component(c, "components") for c in components ] - # FIXME: add accent_color - self.accent_colour: Optional[Colour] = accent_colour + self._accent_colour: Optional[Colour] = accent_colour self.spoiler: bool = spoiler # these are reimplemented here to store the value in a separate attribute, @@ -97,13 +96,30 @@ def id(self) -> int: def id(self, value: int) -> None: self._id = value + @property + def accent_colour(self) -> Optional[Colour]: + return self._accent_colour + + @accent_colour.setter + def accent_colour(self, value: Optional[Union[int, Colour]]) -> None: + if isinstance(value, int): + self._accent_colour = Colour(value) + elif value is None or isinstance(value, Colour): + self._accent_colour = value + else: + raise TypeError( + f"Expected Colour, int, or None but received {type(value).__name__} instead." + ) + + accent_color = accent_colour + @property def _underlying(self) -> ContainerComponent: return ContainerComponent._raw_construct( type=ComponentType.container, id=self._id, components=[comp._underlying for comp in self.components], - _accent_colour=self.accent_colour.value if self.accent_colour is not None else None, + accent_colour=self._accent_colour, spoiler=self.spoiler, ) From 530f3f92b2d8f144dcbbe7b673a6b00ee0f0b271 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 20 Aug 2025 16:11:51 +0200 Subject: [PATCH 086/104] chore: remove resolved/unnecessary `TODO`s --- disnake/ui/media_gallery.py | 2 -- disnake/ui/text_display.py | 1 - disnake/ui/thumbnail.py | 1 - 3 files changed, 4 deletions(-) diff --git a/disnake/ui/media_gallery.py b/disnake/ui/media_gallery.py index f6aeabdaca..5bbb554120 100644 --- a/disnake/ui/media_gallery.py +++ b/disnake/ui/media_gallery.py @@ -53,8 +53,6 @@ def items(self, values: Sequence[MediaGalleryItem]) -> None: @classmethod def from_component(cls, media_gallery: MediaGalleryComponent) -> Self: return cls( - # FIXME: this might not work with items created with `attachment://` - # XXX: consider copying items *media_gallery.items, id=media_gallery.id, ) diff --git a/disnake/ui/text_display.py b/disnake/ui/text_display.py index 60dc09bc28..05cd4e1533 100644 --- a/disnake/ui/text_display.py +++ b/disnake/ui/text_display.py @@ -15,7 +15,6 @@ __all__ = ("TextDisplay",) -# XXX: `TextDisplay` vs just `Text` class TextDisplay(UIComponent): """Represents a UI text display. diff --git a/disnake/ui/thumbnail.py b/disnake/ui/thumbnail.py index 002389b0ff..b7c1b376ea 100644 --- a/disnake/ui/thumbnail.py +++ b/disnake/ui/thumbnail.py @@ -93,7 +93,6 @@ def spoiler(self, value: bool) -> None: @classmethod def from_component(cls, thumbnail: ThumbnailComponent) -> Self: return cls( - # FIXME: this might not work with items created with `attachment://` media=thumbnail.media, description=thumbnail.description, spoiler=thumbnail.spoiler, From 69d03bda53c3aac2a5727332e495730cc2a72b08 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 20 Aug 2025 16:34:52 +0200 Subject: [PATCH 087/104] docs: add `id` attribute description to core components --- disnake/components.py | 96 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/disnake/components.py b/disnake/components.py index bd63688589..1bfc660652 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -223,6 +223,12 @@ class ActionRow(Component, Generic[ActionRowChildComponentT]): ---------- children: List[Union[:class:`Button`, :class:`BaseSelectMenu`, :class:`TextInput`]] The children components that this holds, if any. + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + + .. versionadded:: 2.11 """ __slots__: Tuple[str, ...] = ("children",) @@ -275,6 +281,12 @@ class Button(Component): The ID of a purchasable SKU, for premium buttons. Premium buttons additionally cannot have a ``label``, ``url``, or ``emoji``. + .. versionadded:: 2.11 + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + .. versionadded:: 2.11 """ @@ -371,6 +383,12 @@ class BaseSelectMenu(Component): Only available for auto-populated select menus. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + + .. versionadded:: 2.11 """ __slots__: Tuple[str, ...] = ( @@ -449,6 +467,12 @@ class StringSelectMenu(BaseSelectMenu): Whether the select menu is disabled or not. options: List[:class:`SelectOption`] A list of options that can be selected in this select menu. + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + + .. versionadded:: 2.11 """ __slots__: Tuple[str, ...] = ("options",) @@ -499,6 +523,12 @@ class UserSelectMenu(BaseSelectMenu): If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + + .. versionadded:: 2.11 """ __slots__: Tuple[str, ...] = () @@ -539,6 +569,12 @@ class RoleSelectMenu(BaseSelectMenu): If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + + .. versionadded:: 2.11 """ __slots__: Tuple[str, ...] = () @@ -579,6 +615,12 @@ class MentionableSelectMenu(BaseSelectMenu): If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + + .. versionadded:: 2.11 """ __slots__: Tuple[str, ...] = () @@ -622,6 +664,12 @@ class ChannelSelectMenu(BaseSelectMenu): If set, the number of items must be within the bounds set by ``min_values`` and ``max_values``. .. versionadded:: 2.10 + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + + .. versionadded:: 2.11 """ __slots__: Tuple[str, ...] = ("channel_types",) @@ -814,6 +862,12 @@ class TextInput(Component): The minimum length of the text input. max_length: Optional[:class:`int`] The maximum length of the text input. + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + + .. versionadded:: 2.11 """ __slots__: Tuple[str, ...] = ( @@ -886,6 +940,12 @@ class Section(Component): The accessory component displayed next to the section text. components: List[:class:`TextDisplay`] The text items in this section. + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + + .. versionadded:: 2.11 """ __slots__: Tuple[str, ...] = ("accessory", "components") @@ -925,6 +985,12 @@ class TextDisplay(Component): ---------- content: :class:`str` The text displayed by this component. + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + + .. versionadded:: 2.11 """ __slots__: Tuple[str, ...] = ("content",) @@ -1025,6 +1091,12 @@ class Thumbnail(Component): The thumbnail's description ("alt text"), if any. spoiler: :class:`bool` Whether the thumbnail is marked as a spoiler. Defaults to ``False``. + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + + .. versionadded:: 2.11 """ __slots__: Tuple[str, ...] = ( @@ -1072,6 +1144,12 @@ class MediaGallery(Component): ---------- items: List[:class:`MediaGalleryItem`] The images in this gallery. + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + + .. versionadded:: 2.11 """ __slots__: Tuple[str, ...] = ("items",) @@ -1172,6 +1250,12 @@ class FileComponent(Component): size: Optional[:class:`int`] The size of the file. This is available in objects from the API, and ignored when sending. + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + + .. versionadded:: 2.11 """ __slots__: Tuple[str, ...] = ("file", "spoiler", "name", "size") @@ -1215,6 +1299,12 @@ class Separator(Component): Defaults to ``True``. spacing: :class:`SeparatorSpacingSize` The size of the separator padding. + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + + .. versionadded:: 2.11 """ __slots__: Tuple[str, ...] = ("divider", "spacing") @@ -1256,6 +1346,12 @@ class Container(Component): The accent colour of the container. An alias exists under ``accent_color``. spoiler: :class:`bool` Whether the container is marked as a spoiler. Defaults to ``False``. + id: :class:`int` + The numeric identifier for the component. + This is always present in components received from the API, + and unique within a message. + + .. versionadded:: 2.11 """ __slots__: Tuple[str, ...] = ( From 66348858203ff4754331f7586e27bbad5ecf37b4 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 20 Aug 2025 16:46:28 +0200 Subject: [PATCH 088/104] docs: document limits of Section and MediaGallery items --- disnake/ui/media_gallery.py | 2 +- disnake/ui/section.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/disnake/ui/media_gallery.py b/disnake/ui/media_gallery.py index 5bbb554120..88588e9ac2 100644 --- a/disnake/ui/media_gallery.py +++ b/disnake/ui/media_gallery.py @@ -23,7 +23,7 @@ class MediaGallery(UIComponent): Parameters ---------- *items: :class:`.MediaGalleryItem` - The list of images in this gallery. + The list of images in this gallery (up to 10). id: :class:`int` The numeric identifier for the component. Must be unique within the message. If set to ``0`` (the default) when sending a component, the API will assign diff --git a/disnake/ui/section.py b/disnake/ui/section.py index 9f5c40b878..12885b6b83 100644 --- a/disnake/ui/section.py +++ b/disnake/ui/section.py @@ -27,7 +27,7 @@ class Section(UIComponent): Parameters ---------- *components: :class:`~.ui.TextDisplay` - The text items in this section. + The text items in this section (up to 3). accessory: Union[:class:`~.ui.Thumbnail`, :class:`~.ui.Button`] The accessory component displayed next to the section text. id: :class:`int` From ef63a6c86c8bc2afd907680e4151344c7817f27b Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 20 Aug 2025 16:49:38 +0200 Subject: [PATCH 089/104] refactor: rename `SeparatorSpacingSize` -> `SeparatorSpacing` this enum isn't documented/named separately in the current release docs anyway, it was only part of the initial alpha types --- disnake/components.py | 6 +++--- disnake/enums.py | 6 +++--- disnake/types/components.py | 4 ++-- disnake/ui/separator.py | 14 +++++++------- docs/api/components.rst | 6 +++--- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 1bfc660652..3bb3cc7f57 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -27,7 +27,7 @@ ChannelType, ComponentType, SelectDefaultValueType, - SeparatorSpacingSize, + SeparatorSpacing, TextInputStyle, try_enum, ) @@ -1297,7 +1297,7 @@ class Separator(Component): divider: :class:`bool` Whether the separator should be visible, instead of just being vertical padding/spacing. Defaults to ``True``. - spacing: :class:`SeparatorSpacingSize` + spacing: :class:`SeparatorSpacing` The size of the separator padding. id: :class:`int` The numeric identifier for the component. @@ -1316,7 +1316,7 @@ def __init__(self, data: SeparatorComponentPayload) -> None: self.id = data.get("id", 0) self.divider: bool = data.get("divider", True) - self.spacing: SeparatorSpacingSize = try_enum(SeparatorSpacingSize, data.get("spacing", 1)) + self.spacing: SeparatorSpacing = try_enum(SeparatorSpacing, data.get("spacing", 1)) def to_dict(self) -> SeparatorComponentPayload: return { diff --git a/disnake/enums.py b/disnake/enums.py index 12141c9c29..c49f172cc9 100644 --- a/disnake/enums.py +++ b/disnake/enums.py @@ -76,7 +76,7 @@ "PollLayoutType", "VoiceChannelEffectAnimationType", "MessageReferenceType", - "SeparatorSpacingSize", + "SeparatorSpacing", ) @@ -2356,8 +2356,8 @@ class MessageReferenceType(Enum): """Reference used to point to a message at a point in time (forward).""" -class SeparatorSpacingSize(Enum): - """Specifies the size of a :class:`Separator` component. +class SeparatorSpacing(Enum): + """Specifies the size of a :class:`Separator` component's padding. .. versionadded:: 2.11 """ diff --git a/disnake/types/components.py b/disnake/types/components.py index 8e1826e23b..f388195d77 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -13,7 +13,7 @@ ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextInputStyle = Literal[1, 2] -SeparatorSpacingSize = Literal[1, 2] +SeparatorSpacing = Literal[1, 2] SelectDefaultValueType = Literal["user", "role", "channel"] @@ -218,7 +218,7 @@ class FileComponent(_BaseComponent): class SeparatorComponent(_BaseComponent): type: Literal[14] divider: NotRequired[bool] - spacing: NotRequired[SeparatorSpacingSize] + spacing: NotRequired[SeparatorSpacing] class ContainerComponent(_BaseComponent): diff --git a/disnake/ui/separator.py b/disnake/ui/separator.py index 779693531b..1f62b686b4 100644 --- a/disnake/ui/separator.py +++ b/disnake/ui/separator.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, ClassVar, Tuple from ..components import Separator as SeparatorComponent -from ..enums import ComponentType, SeparatorSpacingSize +from ..enums import ComponentType, SeparatorSpacing from ..utils import MISSING from .item import UIComponent @@ -25,9 +25,9 @@ class Separator(UIComponent): divider: :class:`bool` Whether the separator should be visible, instead of just being vertical padding/spacing. Defaults to ``True``. - spacing: :class:`.SeparatorSpacingSize` + spacing: :class:`.SeparatorSpacing` The size of the separator padding. - Defaults to :attr:`~.SeparatorSpacingSize.small`. + Defaults to :attr:`~.SeparatorSpacing.small`. id: :class:`int` The numeric identifier for the component. Must be unique within the message. If set to ``0`` (the default) when sending a component, the API will assign @@ -45,7 +45,7 @@ def __init__( self, *, divider: bool = True, - spacing: SeparatorSpacingSize = SeparatorSpacingSize.small, + spacing: SeparatorSpacing = SeparatorSpacing.small, id: int = 0, ) -> None: self._underlying = SeparatorComponent._raw_construct( @@ -65,12 +65,12 @@ def divider(self, value: bool) -> None: self._underlying.divider = value @property - def spacing(self) -> SeparatorSpacingSize: - """:class:`.SeparatorSpacingSize`: The size of the separator.""" + def spacing(self) -> SeparatorSpacing: + """:class:`.SeparatorSpacing`: The size of the separator.""" return self._underlying.spacing @spacing.setter - def spacing(self, value: SeparatorSpacingSize) -> None: + def spacing(self, value: SeparatorSpacing) -> None: self._underlying.spacing = value @classmethod diff --git a/docs/api/components.rst b/docs/api/components.rst index e90a4e1378..0ebb57142e 100644 --- a/docs/api/components.rst +++ b/docs/api/components.rst @@ -231,8 +231,8 @@ SelectDefaultValueType .. autoclass:: SelectDefaultValueType() :members: -SeparatorSpacingSize -~~~~~~~~~~~~~~~~~~~~ +SeparatorSpacing +~~~~~~~~~~~~~~~~ -.. autoclass:: SeparatorSpacingSize() +.. autoclass:: SeparatorSpacing() :members: From e906012f1bd9d1d54e3d3f62bf0ffc24895d23f8 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 20 Aug 2025 17:07:45 +0200 Subject: [PATCH 090/104] refactor!: rename `Section./Container.components` to `children` to match ActionRow also sort attributes to have `children` first because it was driving me insane with how inconsistent it turned out over time --- disnake/components.py | 30 +++++++++++++++--------------- disnake/ui/action_row.py | 4 ++-- disnake/ui/container.py | 12 ++++++------ disnake/ui/section.py | 10 +++++----- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index 3bb3cc7f57..c17b3c87c0 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -936,10 +936,10 @@ class Section(Component): Attributes ---------- + children: List[:class:`TextDisplay`] + The text items in this section. accessory: Union[:class:`Thumbnail`, :class:`Button`] The accessory component displayed next to the section text. - components: List[:class:`TextDisplay`] - The text items in this section. id: :class:`int` The numeric identifier for the component. This is always present in components received from the API, @@ -948,7 +948,7 @@ class Section(Component): .. versionadded:: 2.11 """ - __slots__: Tuple[str, ...] = ("accessory", "components") + __slots__: Tuple[str, ...] = ("children", "accessory") __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ @@ -956,19 +956,19 @@ def __init__(self, data: SectionComponentPayload) -> None: self.type: Literal[ComponentType.section] = ComponentType.section self.id = data.get("id", 0) - accessory = _component_factory(data["accessory"]) - self.accessory: SectionAccessoryComponent = accessory # type: ignore - - self.components: List[SectionChildComponent] = [ + self.children: List[SectionChildComponent] = [ _component_factory(d, type=SectionChildComponent) for d in data.get("components", []) ] + accessory = _component_factory(data["accessory"]) + self.accessory: SectionAccessoryComponent = accessory # type: ignore + def to_dict(self) -> SectionComponentPayload: return { "type": self.type.value, "id": self.id, "accessory": self.accessory.to_dict(), - "components": [child.to_dict() for child in self.components], + "components": [child.to_dict() for child in self.children], } @@ -1340,8 +1340,8 @@ class Container(Component): Attributes ---------- - components: List[Union[:class:`ActionRow`, :class:`Section`, :class:`TextDisplay`, :class:`MediaGallery`, :class:`FileComponent`, :class:`Separator`]] - The components in this container. + children: List[Union[:class:`ActionRow`, :class:`Section`, :class:`TextDisplay`, :class:`MediaGallery`, :class:`FileComponent`, :class:`Separator`]] + The child components in this container. accent_colour: Optional[:class:`Colour`] The accent colour of the container. An alias exists under ``accent_color``. spoiler: :class:`bool` @@ -1355,9 +1355,9 @@ class Container(Component): """ __slots__: Tuple[str, ...] = ( + "children", "accent_colour", "spoiler", - "components", ) __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ @@ -1366,20 +1366,20 @@ def __init__(self, data: ContainerComponentPayload) -> None: self.type: Literal[ComponentType.container] = ComponentType.container self.id = data.get("id", 0) + components = [_component_factory(d) for d in data.get("components", [])] + self.children: List[ContainerChildComponent] = components # type: ignore + self.accent_colour: Optional[Colour] = ( Colour(accent_color) if (accent_color := data.get("accent_color")) is not None else None ) self.spoiler: bool = data.get("spoiler", False) - components = [_component_factory(d) for d in data.get("components", [])] - self.components: List[ContainerChildComponent] = components # type: ignore - def to_dict(self) -> ContainerComponentPayload: payload: ContainerComponentPayload = { "type": self.type.value, "id": self.id, "spoiler": self.spoiler, - "components": [child.to_dict() for child in self.components], + "components": [child.to_dict() for child in self.children], } if self.accent_colour is not None: diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index c39d14b06d..7a369a2aec 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -1054,10 +1054,10 @@ def _walk_internal(component: ComponentT, seen: Set[ComponentT]) -> Iterator[Com yield from _walk_internal(item, seen) elif isinstance(component, (SectionComponent, Section)): yield from _walk_internal(component.accessory, seen) - for item in component.components: + for item in component.children: yield from _walk_internal(item, seen) # type: ignore # this is fine, pyright loses the conditional type when iterating elif isinstance(component, (ContainerComponent, Container)): - for item in component.components: + for item in component.children: yield from _walk_internal(item, seen) # type: ignore diff --git a/disnake/ui/container.py b/disnake/ui/container.py index eec24cc5fd..f061d496ea 100644 --- a/disnake/ui/container.py +++ b/disnake/ui/container.py @@ -54,8 +54,8 @@ class Container(UIComponent): Attributes ---------- - components: List[Union[:class:`~.ui.ActionRow`, :class:`~.ui.Section`, :class:`~.ui.TextDisplay`, :class:`~.ui.MediaGallery`, :class:`~.ui.File`, :class:`~.ui.Separator`]] - The list of components in this container. + children: List[Union[:class:`~.ui.ActionRow`, :class:`~.ui.Section`, :class:`~.ui.TextDisplay`, :class:`~.ui.MediaGallery`, :class:`~.ui.File`, :class:`~.ui.Separator`]] + The list of child components in this container. accent_colour: Optional[:class:`.Colour`] The accent colour of the container. An alias exists under ``accent_color``. @@ -64,7 +64,7 @@ class Container(UIComponent): """ __repr_attributes__: ClassVar[Tuple[str, ...]] = ( - "components", + "children", "accent_colour", "spoiler", ) @@ -79,7 +79,7 @@ def __init__( self._id: int = id # this list can be modified without any runtime checks later on, # just assume the user knows what they're doing at that point - self.components: List[ContainerChildUIComponent] = [ + self.children: List[ContainerChildUIComponent] = [ ensure_ui_component(c, "components") for c in components ] self._accent_colour: Optional[Colour] = accent_colour @@ -118,7 +118,7 @@ def _underlying(self) -> ContainerComponent: return ContainerComponent._raw_construct( type=ComponentType.container, id=self._id, - components=[comp._underlying for comp in self.components], + children=[comp._underlying for comp in self.children], accent_colour=self._accent_colour, spoiler=self.spoiler, ) @@ -130,7 +130,7 @@ def from_component(cls, container: ContainerComponent) -> Self: return cls( *cast( "List[ContainerChildUIComponent]", - [_to_ui_component(c) for c in container.components], + [_to_ui_component(c) for c in container.children], ), accent_colour=container.accent_colour, spoiler=container.spoiler, diff --git a/disnake/ui/section.py b/disnake/ui/section.py index 12885b6b83..cb2c95e992 100644 --- a/disnake/ui/section.py +++ b/disnake/ui/section.py @@ -37,14 +37,14 @@ class Section(UIComponent): Attributes ---------- - components: List[:class:`~.ui.TextDisplay`] + children: List[:class:`~.ui.TextDisplay`] The list of text items in this section. accessory: Union[:class:`~.ui.Thumbnail`, :class:`~.ui.Button`] The accessory component displayed next to the section text. """ __repr_attributes__: ClassVar[Tuple[str, ...]] = ( - "components", + "children", "accessory", ) @@ -58,7 +58,7 @@ def __init__( self._id: int = id # this list can be modified without any runtime checks later on, # just assume the user knows what they're doing at that point - self.components: List[TextDisplay] = [ + self.children: List[TextDisplay] = [ ensure_ui_component(c, "components") for c in components ] self.accessory: Union[Thumbnail, Button[Any]] = ensure_ui_component(accessory, "accessory") @@ -79,7 +79,7 @@ def _underlying(self) -> SectionComponent: return SectionComponent._raw_construct( type=ComponentType.section, id=self._id, - components=[comp._underlying for comp in self.components], + children=[comp._underlying for comp in self.children], accessory=self.accessory._underlying, ) @@ -90,7 +90,7 @@ def from_component(cls, section: SectionComponent) -> Self: return cls( *cast( "List[TextDisplay]", - [_to_ui_component(c) for c in section.components], + [_to_ui_component(c) for c in section.children], ), accessory=cast("Union[Thumbnail, Button[Any]]", _to_ui_component(section.accessory)), id=section.id, From b95ed4cdb586c84c2a3936228eed48bedef79814 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 20 Aug 2025 20:27:36 +0200 Subject: [PATCH 091/104] feat: add `Component.is_v2` attribute --- disnake/components.py | 18 ++++++++++++++++++ disnake/types/components.py | 1 - disnake/ui/item.py | 4 ++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/disnake/components.py b/disnake/components.py index c17b3c87c0..fca1ad8eb7 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -187,6 +187,10 @@ class Component: __slots__: Tuple[str, ...] = ("type", "id") __repr_info__: ClassVar[Tuple[str, ...]] + + # subclasses are expected to overwrite this if they're only usable with `MessageFlags.is_components_v2` + is_v2: ClassVar[bool] = False + type: ComponentType id: int @@ -952,6 +956,8 @@ class Section(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + is_v2 = True + def __init__(self, data: SectionComponentPayload) -> None: self.type: Literal[ComponentType.section] = ComponentType.section self.id = data.get("id", 0) @@ -997,6 +1003,8 @@ class TextDisplay(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + is_v2 = True + def __init__(self, data: TextDisplayComponentPayload) -> None: self.type: Literal[ComponentType.text_display] = ComponentType.text_display self.id = data.get("id", 0) @@ -1107,6 +1115,8 @@ class Thumbnail(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + is_v2 = True + def __init__(self, data: ThumbnailComponentPayload) -> None: self.type: Literal[ComponentType.thumbnail] = ComponentType.thumbnail self.id = data.get("id", 0) @@ -1156,6 +1166,8 @@ class MediaGallery(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + is_v2 = True + def __init__(self, data: MediaGalleryComponentPayload) -> None: self.type: Literal[ComponentType.media_gallery] = ComponentType.media_gallery self.id = data.get("id", 0) @@ -1262,6 +1274,8 @@ class FileComponent(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + is_v2 = True + def __init__(self, data: FileComponentPayload) -> None: self.type: Literal[ComponentType.file] = ComponentType.file self.id = data.get("id", 0) @@ -1311,6 +1325,8 @@ class Separator(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + is_v2 = True + def __init__(self, data: SeparatorComponentPayload) -> None: self.type: Literal[ComponentType.separator] = ComponentType.separator self.id = data.get("id", 0) @@ -1362,6 +1378,8 @@ class Container(Component): __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + is_v2 = True + def __init__(self, data: ContainerComponentPayload) -> None: self.type: Literal[ComponentType.container] = ComponentType.container self.id = data.get("id", 0) diff --git a/disnake/types/components.py b/disnake/types/components.py index f388195d77..1b6c49697c 100644 --- a/disnake/types/components.py +++ b/disnake/types/components.py @@ -40,7 +40,6 @@ MessageTopLevelComponentV1: TypeAlias = "ActionRow" # currently, all v2 components except Thumbnail MessageTopLevelComponentV2 = Union[ - MessageTopLevelComponentV1, "SectionComponent", "TextDisplayComponent", "MediaGalleryComponent", diff --git a/disnake/ui/item.py b/disnake/ui/item.py index 36e91d441d..ad6ff49c75 100644 --- a/disnake/ui/item.py +++ b/disnake/ui/item.py @@ -82,6 +82,10 @@ def __repr__(self) -> str: ) return f"<{type(self).__name__} {attrs}>" + @property + def is_v2(self) -> bool: + return self._underlying.is_v2 + @property def type(self) -> ComponentType: return self._underlying.type From f705ef680db0ed0d756b76438f277e84af5925c4 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 20 Aug 2025 20:32:24 +0200 Subject: [PATCH 092/104] feat: automatically set `is_components_v2` flag when sending v2 components, raise if other content fields used --- disnake/abc.py | 13 +++++++++-- disnake/interactions/base.py | 45 ++++++++++++++++++++++++++---------- disnake/message.py | 30 +++++++++++++++++------- disnake/ui/action_row.py | 23 +++++++++++++----- disnake/webhook/async_.py | 17 ++++++++++++-- tests/ui/test_action_row.py | 12 +++++++--- 6 files changed, 107 insertions(+), 33 deletions(-) diff --git a/disnake/abc.py b/disnake/abc.py index 9d1b096271..9174ff5ec6 100644 --- a/disnake/abc.py +++ b/disnake/abc.py @@ -1712,6 +1712,7 @@ async def send( "reference parameter must be Message, MessageReference, or PartialMessage" ) from None + is_v2 = False if view is not None and components is not None: raise TypeError("cannot pass both view and components parameter to send()") elif view: @@ -1719,12 +1720,20 @@ async def send( raise TypeError(f"view parameter must be View not {view.__class__!r}") components_payload = view.to_components() elif components: - from .ui.action_row import components_to_dict + from .ui.action_row import normalize_components_to_dict - components_payload = components_to_dict(components) + components_payload, is_v2 = normalize_components_to_dict(components) else: components_payload = None + # set cv2 flag automatically + if is_v2: + flags = MessageFlags._from_value(0 if flags is None else flags.value) + flags.is_components_v2 = True + # components v2 cannot be used with other content fields + if flags and flags.is_components_v2 and (content or embeds or stickers or poll): + raise ValueError("Cannot use v2 components with content, embeds, stickers, or polls") + flags_payload = None if suppress_embeds is not None: flags = MessageFlags._from_value(0 if flags is None else flags.value) diff --git a/disnake/interactions/base.py b/disnake/interactions/base.py index cc005982c3..30db3430c2 100644 --- a/disnake/interactions/base.py +++ b/disnake/interactions/base.py @@ -49,7 +49,7 @@ from ..object import Object from ..permissions import Permissions from ..role import Role -from ..ui.action_row import components_to_dict, normalize_components +from ..ui.action_row import normalize_components, normalize_components_to_dict from ..user import ClientUser, User from ..webhook.async_ import Webhook, async_context, handle_message_parameters @@ -1093,6 +1093,23 @@ async def send_message( if content is not None: payload["content"] = str(content) + is_v2 = False + if view is not MISSING: + payload["components"] = view.to_components() + elif components is not MISSING: + payload["components"], is_v2 = normalize_components_to_dict(components) + + # set cv2 flag automatically + if is_v2: + flags = MessageFlags._from_value(0 if flags is MISSING else flags.value) + flags.is_components_v2 = True + # components v2 cannot be used with other content fields + if flags and flags.is_components_v2 and (content or embeds or poll): + raise ValueError("Cannot use v2 components with content, embeds, or polls") + + if poll is not MISSING: + payload["poll"] = poll._to_dict() + if suppress_embeds is not MISSING or ephemeral is not MISSING: flags = MessageFlags._from_value(0 if flags is MISSING else flags.value) if suppress_embeds is not MISSING: @@ -1102,14 +1119,6 @@ async def send_message( if flags is not MISSING: payload["flags"] = flags.value - if view is not MISSING: - payload["components"] = view.to_components() - - if components is not MISSING: - payload["components"] = components_to_dict(components) - if poll is not MISSING: - payload["poll"] = poll._to_dict() - parent = self._parent adapter = async_context.get() response_type = InteractionResponseType.channel_message @@ -1298,12 +1307,24 @@ async def edit_message( if view is not MISSING and components is not MISSING: raise TypeError("cannot mix view and components keyword arguments") + is_v2 = False if view is not MISSING: state.prevent_view_updates_for(message.id) payload["components"] = [] if view is None else view.to_components() - - if components is not MISSING: - payload["components"] = [] if components is None else components_to_dict(components) + elif components is not MISSING: + if components: + payload["components"], is_v2 = normalize_components_to_dict(components) + else: + payload["components"] = [] + + flags = None # FIXME: temporary, add `flags` parameter and add to payload + # set cv2 flag automatically + if is_v2: + flags = MessageFlags._from_value(0 if flags is None else flags.value) + flags.is_components_v2 = True + # components v2 cannot be used with other content fields + if flags and flags.is_components_v2 and (content or embeds): + raise ValueError("Cannot use v2 components with content or embeds") adapter = async_context.get() response_type = InteractionResponseType.message_update diff --git a/disnake/message.py b/disnake/message.py index 558ef3862f..e45e637d4a 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -188,12 +188,6 @@ async def _edit_handler( files = files or [] files.extend(embed._files.values()) - if suppress_embeds is not MISSING: - flags = MessageFlags._from_value(default_flags if flags is MISSING else flags.value) - flags.suppress_embeds = suppress_embeds - if flags is not MISSING: - payload["flags"] = flags.value - if allowed_mentions is MISSING: if previous_allowed_mentions: payload["allowed_mentions"] = previous_allowed_mentions.to_dict() @@ -216,10 +210,30 @@ async def _edit_handler( else: payload["components"] = [] + is_v2 = False if components is not MISSING: - from .ui.action_row import components_to_dict + from .ui.action_row import normalize_components_to_dict - payload["components"] = [] if components is None else components_to_dict(components) + if components: + payload["components"], is_v2 = normalize_components_to_dict(components) + else: + payload["components"] = [] + + # set cv2 flag automatically + if is_v2: + flags = MessageFlags._from_value(0 if flags is MISSING else flags.value) + flags.is_components_v2 = True + # components v2 cannot be used with other content fields + # (n.b. this doesn't take into account editing messages that *already* have content/embeds, + # since we can't know that for certain with partial messages anyway) + if flags and flags.is_components_v2 and (content or embeds): + raise ValueError("Cannot use v2 components with content or embeds") + + if suppress_embeds is not MISSING: + flags = MessageFlags._from_value(default_flags if flags is MISSING else flags.value) + flags.suppress_embeds = suppress_embeds + if flags is not MISSING: + payload["flags"] = flags.value try: data = await msg._state.http.edit_message(msg.channel.id, msg.id, **payload, files=files) diff --git a/disnake/ui/action_row.py b/disnake/ui/action_row.py index 7a369a2aec..949f5a12e1 100644 --- a/disnake/ui/action_row.py +++ b/disnake/ui/action_row.py @@ -4,6 +4,7 @@ from typing import ( TYPE_CHECKING, + Any, ClassVar, Generator, Generic, @@ -986,6 +987,9 @@ def normalize_components( def normalize_components( components: ComponentInput[ActionRowChildT, NonActionRowChildT], / ) -> Sequence[Union[ActionRow[ActionRowChildT], NonActionRowChildT]]: + """Wraps consecutive actionrow-compatible components or lists in `ActionRow`s, + while respecting the width limit. Other components are returned as-is. + """ if not isinstance(components, Sequence): components = [components] @@ -1027,13 +1031,20 @@ def normalize_components( return result -def components_to_dict( +def normalize_components_to_dict( components: ComponentInput[ActionRowChildT, NonActionRowChildT], -) -> List[MessageTopLevelComponentPayload]: - return [ - cast("MessageTopLevelComponentPayload", c.to_component_dict()) - for c in normalize_components(components) - ] +) -> Tuple[List[MessageTopLevelComponentPayload], bool]: + """`normalize_components`, but also turns components into dicts. + Returns ([d1, d2, ...], has_v2_component). + """ + component_payloads: List[Mapping[str, Any]] = [] + is_v2 = False + + for c in normalize_components(components): + component_payloads.append(c.to_component_dict()) + is_v2 |= c.is_v2 + + return cast("List[MessageTopLevelComponentPayload]", component_payloads), is_v2 ComponentT = TypeVar("ComponentT", Component, UIComponent) diff --git a/disnake/webhook/async_.py b/disnake/webhook/async_.py index 1d78117570..bbff339a2e 100644 --- a/disnake/webhook/async_.py +++ b/disnake/webhook/async_.py @@ -38,7 +38,7 @@ from ..message import Message from ..mixins import Hashable from ..object import Object -from ..ui.action_row import components_to_dict +from ..ui.action_row import normalize_components_to_dict from ..user import BaseUser, User __all__ = ( @@ -541,10 +541,23 @@ def handle_message_parameters_dict( if content is not MISSING: payload["content"] = str(content) if content is not None else None + + is_v2 = False if view is not MISSING: payload["components"] = view.to_components() if view is not None else [] if components is not MISSING: - payload["components"] = [] if components is None else components_to_dict(components) + if components: + payload["components"], is_v2 = normalize_components_to_dict(components) + else: + payload["components"] = [] + + # set cv2 flag automatically + if is_v2: + flags = MessageFlags._from_value(0 if flags is MISSING else flags.value) + flags.is_components_v2 = True + # components v2 cannot be used with other content fields + if flags and flags.is_components_v2 and (content or embeds or stickers or poll): + raise ValueError("Cannot use v2 components with content, embeds, stickers, or polls") if attachments is not MISSING: payload["attachments"] = [] if attachments is None else [a.to_dict() for a in attachments] diff --git a/tests/ui/test_action_row.py b/tests/ui/test_action_row.py index 088ff30d34..dde75799f7 100644 --- a/tests/ui/test_action_row.py +++ b/tests/ui/test_action_row.py @@ -16,7 +16,7 @@ WrappedComponent, ) from disnake.ui._types import ActionRowMessageComponent, ActionRowModalComponent -from disnake.ui.action_row import components_to_dict, normalize_components +from disnake.ui.action_row import normalize_components, normalize_components_to_dict button1 = Button() button2 = Button() @@ -279,8 +279,8 @@ def test_normalize_components__invalid() -> None: normalize_components(value) # type: ignore -def test_components_to_dict() -> None: - result = components_to_dict([button1, button2, select, ActionRow(button3)]) +def test_normalize_components_to_dict() -> None: + result, is_v2 = normalize_components_to_dict([button1, button2, select, ActionRow(button3)]) assert result == [ { "type": 1, @@ -298,3 +298,9 @@ def test_components_to_dict() -> None: "components": [button3.to_component_dict()], }, ] + assert not is_v2 + + +def test_normalize_components_to_dict__v2() -> None: + _, is_v2 = normalize_components_to_dict([button1, separator, button2]) + assert is_v2 From 31925f1fa41f6b084f9ff0c42e2013c3a65570da Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 22 Aug 2025 10:48:50 +0200 Subject: [PATCH 093/104] fix: handle `flags` in `InteractionResponse.edit_message` properly --- disnake/interactions/base.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/disnake/interactions/base.py b/disnake/interactions/base.py index a5769d89ec..d068c5fd71 100644 --- a/disnake/interactions/base.py +++ b/disnake/interactions/base.py @@ -1325,10 +1325,9 @@ async def edit_message( else: payload["components"] = [] - flags = None # FIXME: temporary, add `flags` parameter and add to payload # set cv2 flag automatically if is_v2: - flags = MessageFlags._from_value(0 if flags is None else flags.value) + flags = MessageFlags._from_value(0 if flags is MISSING else flags.value) flags.is_components_v2 = True # components v2 cannot be used with other content fields if flags and flags.is_components_v2 and (content or embeds): From 1de2a54adf340fbeb14d83f48f55caa9c0e4feab Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 22 Aug 2025 19:05:00 +0200 Subject: [PATCH 094/104] docs: update `components` parameter descriptions re cv2 --- disnake/abc.py | 8 +++++- disnake/channel.py | 12 ++++++--- disnake/flags.py | 2 ++ disnake/interactions/base.py | 47 +++++++++++++++++++++++++++++++----- disnake/message.py | 16 ++++++++++++ disnake/ui/modal.py | 3 ++- disnake/webhook/async_.py | 31 ++++++++++++++++++++---- 7 files changed, 103 insertions(+), 16 deletions(-) diff --git a/disnake/abc.py b/disnake/abc.py index 9174ff5ec6..3af9a4ab78 100644 --- a/disnake/abc.py +++ b/disnake/abc.py @@ -1614,6 +1614,11 @@ async def send( .. versionadded:: 2.4 + .. note:: + Passing v2 components here automatically sets the :attr:`~.MessageFlags.is_components_v2` flag. + Setting this flag cannot be reverted. Note that this also disables the + ``content``, ``embeds``, ``stickers``, and ``poll`` fields. + suppress_embeds: :class:`bool` Whether to suppress embeds for the message. This hides all the embeds from the UI if set to ``True``. @@ -1648,7 +1653,8 @@ async def send( or the ``reference`` object is not a :class:`.Message`, :class:`.MessageReference` or :class:`.PartialMessage`. ValueError - The ``files`` or ``embeds`` list is too large. + The ``files`` or ``embeds`` list is too large, or + you tried to send v2 components together with ``content``, ``embeds``, ``stickers``, or ``poll``. Returns ------- diff --git a/disnake/channel.py b/disnake/channel.py index ab6cc022a2..0b6f9e3a1e 100644 --- a/disnake/channel.py +++ b/disnake/channel.py @@ -3643,6 +3643,12 @@ async def create_thread( A Discord UI View to add to the message. This cannot be mixed with ``components``. components: |components_type| A list of components to include in the message. This cannot be mixed with ``view``. + + .. note:: + Passing v2 components here automatically sets the :attr:`~MessageFlags.is_components_v2` flag. + Setting this flag cannot be reverted. Note that this also disables the + ``content``, ``embeds``, and ``stickers`` fields. + reason: Optional[:class:`str`] The reason for creating the thread. Shows up on the audit log. @@ -3655,11 +3661,11 @@ async def create_thread( TypeError Specified both ``file`` and ``files``, or you specified both ``embed`` and ``embeds``, - or you specified both ``view`` and ``components``. + or you specified both ``view`` and ``components``, or you have passed an object that is not :class:`File` to ``file`` or ``files``. ValueError - Specified more than 10 embeds, - or more than 10 files. + Specified more than 10 embeds, or more than 10 files, or + you tried to send v2 components together with ``content``, ``embeds``, or ``stickers``. Returns ------- diff --git a/disnake/flags.py b/disnake/flags.py index 00ea89ac80..e74dcc6703 100644 --- a/disnake/flags.py +++ b/disnake/flags.py @@ -709,6 +709,8 @@ def is_components_v2(self): Messages with this flag will use specific components for content layout, instead of :attr:`~Message.content` and :attr:`~Message.embeds`. + Note that once this flag is set on a message, it cannot be reverted. + .. versionadded:: 2.11 """ return 1 << 15 diff --git a/disnake/interactions/base.py b/disnake/interactions/base.py index d068c5fd71..5473abfda3 100644 --- a/disnake/interactions/base.py +++ b/disnake/interactions/base.py @@ -499,6 +499,12 @@ async def edit_original_response( .. versionadded:: 2.4 + .. note:: + Passing v2 components here automatically sets the :attr:`~MessageFlags.is_components_v2` flag. + Setting this flag cannot be reverted. Note that this also disables the + ``content`` and ``embeds`` fields. + If the message previously had any of these fields set, you must set them to ``None``. + poll: :class:`Poll` A poll. This can only be sent after a defer. If not used after a defer the discord API ignore the field. @@ -543,9 +549,10 @@ async def edit_original_response( Forbidden Edited a message that is not yours. TypeError - You specified both ``embed`` and ``embeds`` or ``file`` and ``files`` + You specified both ``embed`` and ``embeds`` or ``file`` and ``files``. ValueError - The length of ``embeds`` was invalid. + The length of ``embeds`` was invalid, or + you tried to send v2 components together with ``content`` or ``embeds``. Returns ------- @@ -727,6 +734,11 @@ async def send( .. versionadded:: 2.4 + .. note:: + Passing v2 components here automatically sets the :attr:`~MessageFlags.is_components_v2` flag. + Setting this flag cannot be reverted. Note that this also disables the + ``content``, ``embeds``, and ``poll`` fields. + ephemeral: :class:`bool` Whether the message should only be visible to the user who started the interaction. If a view is sent with an ephemeral message and it has no timeout set then the timeout @@ -770,7 +782,8 @@ async def send( TypeError You specified both ``embed`` and ``embeds``. ValueError - The length of ``embeds`` was invalid. + The length of ``embeds`` was invalid, or + you tried to send v2 components together with ``content``, ``embeds``, or ``poll``. """ if self.response._response_type is not None: sender = self.followup.send @@ -995,6 +1008,11 @@ async def send_message( .. versionadded:: 2.4 + .. note:: + Passing v2 components here automatically sets the :attr:`~MessageFlags.is_components_v2` flag. + Setting this flag cannot be reverted. Note that this also disables the + ``content``, ``embeds``, and ``poll`` fields. + tts: :class:`bool` Whether the message should be sent using text-to-speech. ephemeral: :class:`bool` @@ -1041,7 +1059,8 @@ async def send_message( TypeError You specified both ``embed`` and ``embeds``. ValueError - The length of ``embeds`` was invalid. + The length of ``embeds`` was invalid, or + you tried to send v2 components together with ``content``, ``embeds``, or ``poll``. InteractionResponded This interaction has already been responded to before. """ @@ -1225,6 +1244,12 @@ async def edit_message( .. versionadded:: 2.4 + .. note:: + Passing v2 components here automatically sets the :attr:`~MessageFlags.is_components_v2` flag. + Setting this flag cannot be reverted. Note that this also disables the + ``content`` and ``embeds`` fields. + If the message previously had any of these fields set, you must set them to ``None``. + flags: :class:`MessageFlags` The new flags to set for this message. Overrides existing flags. Only :attr:`~MessageFlags.suppress_embeds` is supported. @@ -1250,6 +1275,8 @@ async def edit_message( Editing the message failed. TypeError You specified both ``embed`` and ``embeds``. + ValueError + You tried to send v2 components together with ``content`` or ``embeds``. InteractionResponded This interaction has already been responded to before. """ @@ -1457,6 +1484,7 @@ async def send_modal( This cannot be mixed with the ``modal`` parameter. components: |components_type| The components to display in the modal. A maximum of 5. + Currently only supports :class:`ui.TextInput` (optionally inside :class:`ui.ActionRow`). This cannot be mixed with the ``modal`` parameter. Raises @@ -1792,6 +1820,12 @@ async def edit( .. versionadded:: 2.4 + .. note:: + Passing v2 components here automatically sets the :attr:`~MessageFlags.is_components_v2` flag. + Setting this flag cannot be reverted. Note that this also disables the + ``content`` and ``embeds`` fields. + If the message previously had any of these fields set, you must set them to ``None``. + suppress_embeds: :class:`bool` Whether to suppress embeds for the message. This hides all the embeds from the UI if set to ``True``. If set @@ -1829,9 +1863,10 @@ async def edit( Forbidden Edited a message that is not yours. TypeError - You specified both ``embed`` and ``embeds`` or ``file`` and ``files`` + You specified both ``embed`` and ``embeds`` or ``file`` and ``files``. ValueError - The length of ``embeds`` was invalid. + The length of ``embeds`` was invalid, or + you tried to send v2 components together with ``content`` or ``embeds``. Returns ------- diff --git a/disnake/message.py b/disnake/message.py index e45e637d4a..0298e008ad 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -2058,6 +2058,12 @@ async def edit( .. versionadded:: 2.4 + .. note:: + Passing v2 components here automatically sets the :attr:`~MessageFlags.is_components_v2` flag. + Setting this flag cannot be reverted. Note that this also disables the + ``content`` and ``embeds`` fields. + If the message previously had any of these fields set, you must set them to ``None``. + Raises ------ HTTPException @@ -2067,6 +2073,8 @@ async def edit( edited a message's content or embed that isn't yours. TypeError You specified both ``embed`` and ``embeds``, or ``file`` and ``files``, or ``view`` and ``components``. + ValueError + You tried to send v2 components together with ``content`` or ``embeds``. Returns ------- @@ -2822,6 +2830,12 @@ async def edit( .. versionadded:: 2.4 + .. note:: + Passing v2 components here automatically sets the :attr:`~MessageFlags.is_components_v2` flag. + Setting this flag cannot be reverted. Note that this also disables the + ``content`` and ``embeds`` fields. + If the message previously had any of these fields set, you must set them to ``None``. + Raises ------ NotFound @@ -2833,6 +2847,8 @@ async def edit( edited a message's content or embed that isn't yours. TypeError You specified both ``embed`` and ``embeds``, or ``file`` and ``files``, or ``view`` and ``components``. + ValueError + You tried to send v2 components together with ``content`` or ``embeds``. Returns ------- diff --git a/disnake/ui/modal.py b/disnake/ui/modal.py index f9b90cd5a1..ef1ae0a234 100644 --- a/disnake/ui/modal.py +++ b/disnake/ui/modal.py @@ -37,7 +37,8 @@ class Modal: title: :class:`str` The title of the modal. components: |components_type| - The components to display in the modal. Up to 5 action rows. + The components to display in the modal. A maximum of 5. + Currently only supports :class:`.ui.TextInput` (optionally inside :class:`.ui.ActionRow`). custom_id: :class:`str` The custom ID of the modal. This is usually not required. If not given, then a unique one is generated for you. diff --git a/disnake/webhook/async_.py b/disnake/webhook/async_.py index fbfc3866b2..301d714cb6 100644 --- a/disnake/webhook/async_.py +++ b/disnake/webhook/async_.py @@ -868,6 +868,12 @@ async def edit( .. versionadded:: 2.4 + .. note:: + Passing v2 components here automatically sets the :attr:`~MessageFlags.is_components_v2` flag. + Setting this flag cannot be reverted. Note that this also disables the + ``content`` and ``embeds`` fields. + If the message previously had any of these fields set, you must set them to ``None``. + flags: :class:`MessageFlags` The new flags to set for this message. Overrides existing flags. Only :attr:`~MessageFlags.suppress_embeds` is supported. @@ -885,9 +891,10 @@ async def edit( Forbidden Edited a message that is not yours. TypeError - You specified both ``embed`` and ``embeds`` or ``file`` and ``files`` + You specified both ``embed`` and ``embeds`` or ``file`` and ``files``. ValueError - The length of ``embeds`` was invalid + The length of ``embeds`` was invalid, or + you tried to send v2 components together with ``content`` or ``embeds``. WebhookTokenMissing There was no token associated with this webhook. @@ -1654,6 +1661,11 @@ async def send( .. versionadded:: 2.4 + .. note:: + Passing v2 components here automatically sets the :attr:`~MessageFlags.is_components_v2` flag. + Setting this flag cannot be reverted. Note that this also disables the + ``content``, ``embeds``, and ``poll`` fields. + thread: :class:`~disnake.abc.Snowflake` The thread to send this message to. @@ -1727,7 +1739,8 @@ async def send( WebhookTokenMissing There was no token associated with this webhook. ValueError - The length of ``embeds`` was invalid. + The length of ``embeds`` was invalid, or + you tried to send v2 components together with ``content``, ``embeds``, or ``poll``. Returns ------- @@ -1945,11 +1958,18 @@ async def edit_message( .. versionadded:: 2.0 - components: |components_type| + components: Optional[|components_type|] A list of components to update this message with. This cannot be mixed with ``view``. + If ``None`` is passed then the components are removed. .. versionadded:: 2.4 + .. note:: + Passing v2 components here automatically sets the :attr:`~MessageFlags.is_components_v2` flag. + Setting this flag cannot be reverted. Note that this also disables the + ``content`` and ``embeds`` fields. + If the message previously had any of these fields set, you must set them to ``None``. + flags: :class:`MessageFlags` The new flags to set for this message. Overrides existing flags. Only :attr:`~MessageFlags.suppress_embeds` is supported. @@ -1977,7 +1997,8 @@ async def edit_message( WebhookTokenMissing There was no token associated with this webhook. ValueError - The length of ``embeds`` was invalid + The length of ``embeds`` was invalid, or + you tried to send v2 components together with ``content`` or ``embeds``. Returns ------- From 8fc208746248015c3dfde970e3be769d10c876fb Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 22 Aug 2025 19:13:16 +0200 Subject: [PATCH 095/104] docs: add `is_components_v2` flag to `flags` parameter description --- disnake/abc.py | 4 ++-- disnake/channel.py | 3 ++- disnake/interactions/base.py | 19 ++++++++++++------- disnake/message.py | 6 ++++-- disnake/webhook/async_.py | 11 +++++++---- 5 files changed, 27 insertions(+), 16 deletions(-) diff --git a/disnake/abc.py b/disnake/abc.py index 3af9a4ab78..5ae39bf3a9 100644 --- a/disnake/abc.py +++ b/disnake/abc.py @@ -1627,8 +1627,8 @@ async def send( flags: :class:`.MessageFlags` The flags to set for this message. - Only :attr:`~.MessageFlags.suppress_embeds` and :attr:`~.MessageFlags.suppress_notifications` - are supported. + Only :attr:`~.MessageFlags.suppress_embeds`, :attr:`~.MessageFlags.suppress_notifications`, + and :attr:`~.MessageFlags.is_components_v2` are supported. If parameter ``suppress_embeds`` is provided, that will override the setting of :attr:`.MessageFlags.suppress_embeds`. diff --git a/disnake/channel.py b/disnake/channel.py index 0b6f9e3a1e..8b519aa46c 100644 --- a/disnake/channel.py +++ b/disnake/channel.py @@ -3618,7 +3618,8 @@ async def create_thread( all the embeds from the UI if set to ``True``. flags: :class:`MessageFlags` The flags to set for this message. - Only :attr:`~MessageFlags.suppress_embeds` is supported. + Only :attr:`~MessageFlags.suppress_embeds` and :attr:`~MessageFlags.is_components_v2` + are supported. If parameter ``suppress_embeds`` is provided, that will override the setting of :attr:`MessageFlags.suppress_embeds`. diff --git a/disnake/interactions/base.py b/disnake/interactions/base.py index 5473abfda3..c9f7b564a2 100644 --- a/disnake/interactions/base.py +++ b/disnake/interactions/base.py @@ -525,7 +525,8 @@ async def edit_original_response( flags: :class:`MessageFlags` The new flags to set for this message. Overrides existing flags. - Only :attr:`~MessageFlags.suppress_embeds` is supported. + Only :attr:`~MessageFlags.suppress_embeds` and :attr:`~MessageFlags.is_components_v2` + are supported. If parameter ``suppress_embeds`` is provided, that will override the setting of :attr:`.MessageFlags.suppress_embeds`. @@ -751,8 +752,9 @@ async def send( flags: :class:`MessageFlags` The flags to set for this message. - Only :attr:`~MessageFlags.suppress_embeds`, :attr:`~MessageFlags.ephemeral` - and :attr:`~MessageFlags.suppress_notifications` are supported. + Only :attr:`~MessageFlags.suppress_embeds`, :attr:`~MessageFlags.ephemeral`, + :attr:`~MessageFlags.suppress_notifications`, and :attr:`~MessageFlags.is_components_v2` + are supported. If parameters ``suppress_embeds`` or ``ephemeral`` are provided, they will override the corresponding setting of this ``flags`` parameter. @@ -1038,8 +1040,9 @@ async def send_message( flags: :class:`MessageFlags` The flags to set for this message. - Only :attr:`~MessageFlags.suppress_embeds`, :attr:`~MessageFlags.ephemeral` - and :attr:`~MessageFlags.suppress_notifications` are supported. + Only :attr:`~MessageFlags.suppress_embeds`, :attr:`~MessageFlags.ephemeral`, + :attr:`~MessageFlags.suppress_notifications`, and :attr:`~MessageFlags.is_components_v2` + are supported. If parameters ``suppress_embeds`` or ``ephemeral`` are provided, they will override the corresponding setting of this ``flags`` parameter. @@ -1252,7 +1255,8 @@ async def edit_message( flags: :class:`MessageFlags` The new flags to set for this message. Overrides existing flags. - Only :attr:`~MessageFlags.suppress_embeds` is supported. + Only :attr:`~MessageFlags.suppress_embeds` and :attr:`~MessageFlags.is_components_v2` + are supported. .. versionadded:: 2.11 @@ -1836,7 +1840,8 @@ async def edit( flags: :class:`MessageFlags` The new flags to set for this message. Overrides existing flags. - Only :attr:`~MessageFlags.suppress_embeds` is supported. + Only :attr:`~MessageFlags.suppress_embeds` and :attr:`~MessageFlags.is_components_v2` + are supported. If parameter ``suppress_embeds`` is provided, that will override the setting of :attr:`.MessageFlags.suppress_embeds`. diff --git a/disnake/message.py b/disnake/message.py index 0298e008ad..dba239a1e0 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -2025,7 +2025,8 @@ async def edit( suppressed. flags: :class:`MessageFlags` The new flags to set for this message. Overrides existing flags. - Only :attr:`~MessageFlags.suppress_embeds` is supported. + Only :attr:`~MessageFlags.suppress_embeds` and :attr:`~MessageFlags.is_components_v2` + are supported. If parameter ``suppress_embeds`` is provided, that will override the setting of :attr:`.MessageFlags.suppress_embeds`. @@ -2798,7 +2799,8 @@ async def edit( suppressed. flags: :class:`MessageFlags` The new flags to set for this message. Overrides existing flags. - Only :attr:`~MessageFlags.suppress_embeds` is supported. + Only :attr:`~MessageFlags.suppress_embeds` and :attr:`~MessageFlags.is_components_v2` + are supported. If parameter ``suppress_embeds`` is provided, that will override the setting of :attr:`.MessageFlags.suppress_embeds`. diff --git a/disnake/webhook/async_.py b/disnake/webhook/async_.py index 301d714cb6..2081a417be 100644 --- a/disnake/webhook/async_.py +++ b/disnake/webhook/async_.py @@ -876,7 +876,8 @@ async def edit( flags: :class:`MessageFlags` The new flags to set for this message. Overrides existing flags. - Only :attr:`~MessageFlags.suppress_embeds` is supported. + Only :attr:`~MessageFlags.suppress_embeds` and :attr:`~MessageFlags.is_components_v2` + are supported. .. versionadded:: 2.11 @@ -1709,8 +1710,9 @@ async def send( flags: :class:`MessageFlags` The flags to set for this message. - Only :attr:`~MessageFlags.suppress_embeds`, :attr:`~MessageFlags.ephemeral` - and :attr:`~MessageFlags.suppress_notifications` are supported. + Only :attr:`~MessageFlags.suppress_embeds`, :attr:`~MessageFlags.ephemeral`, + :attr:`~MessageFlags.suppress_notifications`, and :attr:`~MessageFlags.is_components_v2` + are supported. If parameters ``suppress_embeds`` or ``ephemeral`` are provided, they will override the corresponding setting of this ``flags`` parameter. @@ -1972,7 +1974,8 @@ async def edit_message( flags: :class:`MessageFlags` The new flags to set for this message. Overrides existing flags. - Only :attr:`~MessageFlags.suppress_embeds` is supported. + Only :attr:`~MessageFlags.suppress_embeds` and :attr:`~MessageFlags.is_components_v2` + are supported. .. versionadded:: 2.11 From 479f438cd745839e5748ddb7ab07a6bc150d7056 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Fri, 22 Aug 2025 19:21:05 +0200 Subject: [PATCH 096/104] docs: remove module prefix from `|components_type|` shortcut --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 65452b3b7f..d521aabc52 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -78,7 +78,7 @@ .. |coro| replace:: This function is a |coroutine_link|_. .. |maybecoro| replace:: This function *could be a* |coroutine_link|_. .. |coroutine_link| replace:: *coroutine* -.. |components_type| replace:: Union[:class:`disnake.ui.UIComponent`, List[Union[:class:`disnake.ui.UIComponent`, List[:class:`disnake.ui.WrappedComponent`]]]] +.. |components_type| replace:: Union[:class:`~disnake.ui.UIComponent`, List[Union[:class:`~disnake.ui.UIComponent`, List[:class:`~disnake.ui.WrappedComponent`]]]] .. |resource_type| replace:: Union[:class:`bytes`, :class:`.Asset`, :class:`.Emoji`, :class:`.PartialEmoji`, :class:`.StickerItem`, :class:`.Sticker`] .. _coroutine_link: https://docs.python.org/3/library/asyncio-task.html#coroutine """ From 9a566f9589f3c7b4800f27d0de4543eff62ca3c1 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 23 Aug 2025 10:21:54 +0200 Subject: [PATCH 097/104] fix: keep previous flags when editing message with v2 components --- disnake/message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/disnake/message.py b/disnake/message.py index dba239a1e0..9f1fe74d19 100644 --- a/disnake/message.py +++ b/disnake/message.py @@ -221,7 +221,7 @@ async def _edit_handler( # set cv2 flag automatically if is_v2: - flags = MessageFlags._from_value(0 if flags is MISSING else flags.value) + flags = MessageFlags._from_value(default_flags if flags is MISSING else flags.value) flags.is_components_v2 = True # components v2 cannot be used with other content fields # (n.b. this doesn't take into account editing messages that *already* have content/embeds, From 538fefb42f915ee0249862aadd1b1d8f9d892cfe Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 23 Aug 2025 11:23:34 +0200 Subject: [PATCH 098/104] feat: set `?with_components=1` on webhook requests by default --- disnake/flags.py | 2 ++ disnake/webhook/async_.py | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/disnake/flags.py b/disnake/flags.py index e74dcc6703..28435ae4a2 100644 --- a/disnake/flags.py +++ b/disnake/flags.py @@ -708,6 +708,8 @@ def is_components_v2(self): Messages with this flag will use specific components for content layout, instead of :attr:`~Message.content` and :attr:`~Message.embeds`. + Further details, limits, and example images can be found + in the :ddocs:`API documentation `. Note that once this flag is set on a message, it cannot be reverted. diff --git a/disnake/webhook/async_.py b/disnake/webhook/async_.py index 2081a417be..3457cebcc2 100644 --- a/disnake/webhook/async_.py +++ b/disnake/webhook/async_.py @@ -291,8 +291,9 @@ def execute_webhook( files: Optional[List[File]] = None, thread_id: Optional[int] = None, wait: bool = False, + with_components: bool = True, ) -> Response[Optional[MessagePayload]]: - params = {"wait": int(wait)} + params = {"wait": int(wait), "with_components": int(with_components)} if thread_id: params["thread_id"] = thread_id @@ -1667,6 +1668,10 @@ async def send( Setting this flag cannot be reverted. Note that this also disables the ``content``, ``embeds``, and ``poll`` fields. + .. note:: + Non-application-owned webhooks can only send non-interactive components, + e.g. link buttons or v2 layout components. + thread: :class:`~disnake.abc.Snowflake` The thread to send this message to. From 90d6c2b51cba391cc8bff235ac57e250f708c89f Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 23 Aug 2025 21:31:51 +0200 Subject: [PATCH 099/104] chore: remove more obsolete TODOs --- disnake/components.py | 2 +- disnake/ui/section.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/disnake/components.py b/disnake/components.py index fca1ad8eb7..ff3ef8fa9c 100644 --- a/disnake/components.py +++ b/disnake/components.py @@ -118,7 +118,6 @@ # any child component type of action rows ActionRowChildComponent = Union[ActionRowMessageComponent, ActionRowModalComponent] -# TODO: this might have to be covariant ActionRowChildComponentT = TypeVar("ActionRowChildComponentT", bound=ActionRowChildComponent) # valid `Section.accessory` types @@ -1464,6 +1463,7 @@ def handle_media_item_input(value: MediaItemInput) -> UnfurledMediaItem: # NOTE: The type param is purely for type-checking, it has no implications on runtime behavior. +# FIXME: could be improved with https://peps.python.org/pep-0747/ def _component_factory(data: ComponentPayload, *, type: Type[C] = Component) -> C: component_type = data["type"] diff --git a/disnake/ui/section.py b/disnake/ui/section.py index cb2c95e992..906ffb4490 100644 --- a/disnake/ui/section.py +++ b/disnake/ui/section.py @@ -48,7 +48,6 @@ class Section(UIComponent): "accessory", ) - # TODO: consider providing sequence operations (append, insert, remove, etc.) def __init__( self, *components: TextDisplay, From 3ab6961a8f8aa002bad1e1b7d60282c59a6c3a84 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 23 Aug 2025 21:32:11 +0200 Subject: [PATCH 100/104] chore(docs): copy over missing component descriptions --- disnake/ui/media_gallery.py | 2 ++ disnake/ui/section.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/disnake/ui/media_gallery.py b/disnake/ui/media_gallery.py index 88588e9ac2..a876c06d0d 100644 --- a/disnake/ui/media_gallery.py +++ b/disnake/ui/media_gallery.py @@ -18,6 +18,8 @@ class MediaGallery(UIComponent): """Represents a UI media gallery. + This allows displaying up to 10 images in a gallery. + .. versionadded:: 2.11 Parameters diff --git a/disnake/ui/section.py b/disnake/ui/section.py index 906ffb4490..25e79170a6 100644 --- a/disnake/ui/section.py +++ b/disnake/ui/section.py @@ -22,6 +22,8 @@ class Section(UIComponent): """Represents a UI section. + This allows displaying an accessory (thumbnail or button) next to a block of text. + .. versionadded:: 2.11 Parameters From 7f4a8e215595e40d7241922c805c96ebeda1eb70 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 23 Aug 2025 21:39:57 +0200 Subject: [PATCH 101/104] feat: add `ui.Section(str)` qol shortcut --- disnake/ui/section.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/disnake/ui/section.py b/disnake/ui/section.py index 25e79170a6..72e5bac0c1 100644 --- a/disnake/ui/section.py +++ b/disnake/ui/section.py @@ -8,12 +8,12 @@ from ..enums import ComponentType from ..utils import copy_doc from .item import UIComponent, ensure_ui_component +from .text_display import TextDisplay if TYPE_CHECKING: from typing_extensions import Self from .button import Button - from .text_display import TextDisplay from .thumbnail import Thumbnail __all__ = ("Section",) @@ -28,7 +28,7 @@ class Section(UIComponent): Parameters ---------- - *components: :class:`~.ui.TextDisplay` + *components: Union[:class:`str`, :class:`~.ui.TextDisplay`] The text items in this section (up to 3). accessory: Union[:class:`~.ui.Thumbnail`, :class:`~.ui.Button`] The accessory component displayed next to the section text. @@ -52,7 +52,7 @@ class Section(UIComponent): def __init__( self, - *components: TextDisplay, + *components: Union[str, TextDisplay], accessory: Union[Thumbnail, Button[Any]], id: int = 0, ) -> None: @@ -60,7 +60,8 @@ def __init__( # this list can be modified without any runtime checks later on, # just assume the user knows what they're doing at that point self.children: List[TextDisplay] = [ - ensure_ui_component(c, "components") for c in components + TextDisplay(c) if isinstance(c, str) else ensure_ui_component(c, "components") + for c in components ] self.accessory: Union[Thumbnail, Button[Any]] = ensure_ui_component(accessory, "accessory") From 3afe3fca426c13fd7e0a17683540c31f5dd5da43 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 23 Aug 2025 21:41:03 +0200 Subject: [PATCH 102/104] docs: add examples/components_v2.py --- examples/components_v2.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 examples/components_v2.py diff --git a/examples/components_v2.py b/examples/components_v2.py new file mode 100644 index 0000000000..962cbfe675 --- /dev/null +++ b/examples/components_v2.py @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: MIT + +"""An example showcasing v2/layout components.""" + +import os +from typing import Any + +from disnake import ui +from disnake.ext import commands + +bot = commands.Bot(command_prefix=commands.when_mentioned) + + +@bot.command() +async def send_components(ctx: commands.Context): + media_data: Any = ... # placeholder for actual data + + await ctx.send( + components=[ + ui.TextDisplay("@user's current activity:"), + ui.Container( + ui.Section( + f"Listening to {media_data.title}", + accessory=ui.Thumbnail(media_data.album_cover.url), + ), + ui.ActionRow(ui.Button(label="Open in Browser", url=media_data.link)), + ), + ] + ) + + +@bot.event +async def on_ready(): + print(f"Logged in as {bot.user} (ID: {bot.user.id})\n------") + + +if __name__ == "__main__": + bot.run(os.getenv("BOT_TOKEN")) From 830343ec1fbef9b3277fab40ebe8a2e09889a353 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 23 Aug 2025 21:41:10 +0200 Subject: [PATCH 103/104] docs: finish changelog --- changelog/1294.feature.rst | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/changelog/1294.feature.rst b/changelog/1294.feature.rst index 2a34ea6bdf..86b6efb746 100644 --- a/changelog/1294.feature.rst +++ b/changelog/1294.feature.rst @@ -1 +1,14 @@ -TODO. +Add support for :ddocs:`components v2 ` (`example `_): +- These components allow you to have more control over the layout of your messages, and are used instead of the classic ``content`` and ``embeds`` fields. +- New top-level components: + - :class:`ui.Section`: Displays an accessory (:class:`ui.Thumbnail` or :class:`ui.Button`) alongside some text. + - :class:`ui.TextDisplay`: Text component, similar to the ``content`` field of messages. + - :class:`ui.MediaGallery`: A gallery/mosaic of up to 10 :class:`MediaGalleryItem`\s. + - :class:`ui.File`: Display an uploaded file as an attachment + - :class:`ui.Separator`: A spacer/separator adding vertical padding. + - :class:`ui.Container`: Contains other components, visually similar to :class:`Embed`\s. + - Each component has a corresponding new :class:`ComponentType`. +- New :attr:`MessageFlags.is_components_v2` flag. This is set automatically when sending v2 components, and cannot be reverted once set. +- New :func:`ui.walk_components` and :func:`ui.components_from_message` utility functions. +- All ``ui.*`` components now inherit from a common :class:`ui.UIComponent` base class. +- Components now have an :attr:`~UIComponent.id` attribute, which is a unique (per-message) optional numeric identifier. From 50c8d5532f231c8eff9d48f0bf2a91d1a3cb1c8a Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sat, 23 Aug 2025 21:45:53 +0200 Subject: [PATCH 104/104] chore(docs): fix reference in changelog --- changelog/1294.feature.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog/1294.feature.rst b/changelog/1294.feature.rst index 86b6efb746..5404967d87 100644 --- a/changelog/1294.feature.rst +++ b/changelog/1294.feature.rst @@ -4,11 +4,11 @@ Add support for :ddocs:`components v2 ` (`example