From c68ede80eb0ae7f5c22624c90908ad098274ac5a Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Dec 2025 19:48:42 -0500 Subject: [PATCH 001/117] introduce replace_item and some additional patches --- discord/ui/action_row.py | 25 +++++++++++++++++++++++ discord/ui/container.py | 28 +++++++++++++++++++++++++ discord/ui/file_upload.py | 11 ++++++++++ discord/ui/input_text.py | 15 ++++++++++++++ discord/ui/label.py | 4 ++-- discord/ui/section.py | 33 +++++++++++++++++++++++++++++- discord/ui/view.py | 43 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 156 insertions(+), 3 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 6737a1625b..a27c130c63 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -164,6 +164,31 @@ def remove_item(self, item: ViewItem | str | int) -> Self: item.parent = None return self + def replace_item(self, original_item: ViewItem | str | int, new_item: ViewItem) -> Self: + """Directly replace an item in this row. + If an :class:`int` is provided, the item will be replaced by ``id``, otherwise by ``custom_id``. + + Parameters + ---------- + original_item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] + The item, item ``id``, or item ``custom_id`` to replace in the row. + new_item: :class:`ViewItem` + The new item to insert into the row. + """ + + if isinstance(original_item, (str, int)): + original_item = self.get_item(original_item) + if not original_item: + raise ValueError(f"Could not find original_item in row.") + try: + i = self.children.index(original_item) + new_item.parent = self + self.children[i] = new_item + original_item.parent = None + except ValueError: + raise ValueError(f"Could not find original_item in row.") + return self + def get_item(self, id: str | int) -> ViewItem | None: """Get an item from this action row. Roughly equivalent to `utils.get(row.children, ...)`. If an ``int`` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. diff --git a/discord/ui/container.py b/discord/ui/container.py index 4c738c8fdf..c0ab523db2 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -176,6 +176,34 @@ def remove_item(self, item: ViewItem | str | int) -> Self: item.parent = None return self + def replace_item(self, original_item: ViewItem | str | int, new_item: ViewItem) -> Self: + """Directly replace an item in this container. + If an :class:`int` is provided, the item will be replaced by ``id``, otherwise by ``custom_id``. + + Parameters + ---------- + original_item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] + The item, item ``id``, or item ``custom_id`` to replace in the container. + new_item: :class:`ViewItem` + The new item to insert into the container. + """ + + if isinstance(original_item, (str, int)): + original_item = self.get_item(original_item) + if not original_item: + raise ValueError(f"Could not find original_item in container.") + try: + if original_item.parent is self: + i = self.items.index(original_item) + new_item.parent = self + self.items[i] = new_item + original_item.parent = None + else: + original_item.parent.replace_item(original_item, new_item) + except ValueError: + raise ValueError(f"Could not find original_item in container.") + return self + def get_item(self, id: str | int) -> ViewItem | None: """Get an item from this container. Roughly equivalent to `utils.get(container.items, ...)`. If an ``int`` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. diff --git a/discord/ui/file_upload.py b/discord/ui/file_upload.py index 1f4222b0e0..95a76331bc 100644 --- a/discord/ui/file_upload.py +++ b/discord/ui/file_upload.py @@ -158,3 +158,14 @@ def refresh_from_modal(self, interaction: Interaction, data: dict) -> None: ) for attachment_id in values ] + + @classmethod + def from_component(cls: type[FileUpload], component: FileUploadComponent) -> FileUpload: + + return cls( + custom_id=component.custom_id, + min_values=component.min_values, + max_values=component.max_values, + required=component.required, + id=component.id, + ) diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index 9f8e35bffc..9174e215f1 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -259,5 +259,20 @@ def refresh_from_modal( ) -> None: return self.refresh_state(data) + @classmethod + def from_component(type[InputText], component: InputTextComponent) -> InputText: + + return cls( + style=component.style, + custom_id=component.custom_id, + label=component.label, + placeholder=component.placeholder, + min_length=component.min_length, + max_length=component.max_length, + required=component.required, + value=component.value, + id=component.id, + ) + TextInput = InputText diff --git a/discord/ui/label.py b/discord/ui/label.py index a063964aa7..6de557a702 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -415,8 +415,8 @@ def from_component(cls: type[L], component: LabelComponent) -> L: item = _component_to_item(component.component) return cls( - item, - id=component.id, label=component.label, + item=item, + id=component.id, description=component.description, ) diff --git a/discord/ui/section.py b/discord/ui/section.py index 069b600ea5..b08d09ce38 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -159,12 +159,43 @@ def remove_item(self, item: ViewItem | str | int) -> Self: if isinstance(item, (str, int)): item = self.get_item(item) try: - self.items.remove(item) + if item is self.accessory: + self.accessory = None + else: + self.items.remove(item) except ValueError: pass item.parent = None return self + def replace_item(self, original_item: ViewItem | str | int, new_item: ViewItem) -> Self: + """Directly replace an item in this section. + If an :class:`int` is provided, the item will be replaced by ``id``, otherwise by ``custom_id``. + + Parameters + ---------- + original_item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] + The item, item ``id``, or item ``custom_id`` to replace in the section. + new_item: :class:`ViewItem` + The new item to insert into the section. + """ + + if isinstance(original_item, (str, int)): + original_item = self.get_item(original_item) + if not original_item: + raise ValueError(f"Could not find original_item in section.") + try: + if original_item is self.accessory: + self.accessory = new_item + else: + i = self.items.index(original_item) + self.items[i] = new_item + original_item.parent = None + new_item.parent = self + except ValueError: + raise ValueError(f"Could not find original_item in section.") + return self + def get_item(self, id: int | str) -> ViewItem | None: """Get an item from this section. Alias for `utils.get(section.walk_items(), ...)`. If an ``int`` is provided, it will be retrieved by ``id``, otherwise it will check the accessory's ``custom_id``. diff --git a/discord/ui/view.py b/discord/ui/view.py index ec4c0cd8a7..28af4fd793 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -54,6 +54,8 @@ from ..components import Separator as SeparatorComponent from ..components import TextDisplay as TextDisplayComponent from ..components import Thumbnail as ThumbnailComponent +from ..components import InputText as InputTextComponent +from ..components import FileUpload as FileUploadComponent from ..components import _component_factory from ..enums import ChannelType from ..utils import find @@ -142,6 +144,14 @@ def _component_to_item(component: Component) -> ViewItem[V]: from .label import Label return Label.from_component(component) + if isinstance(component, InputTextComponent): + from .input_text import InputText + + return InputText.from_component(component) + if isinstance(component, FileUploadComponent): + from .file_upload import FileUpload + + return FileUpload.from_component(component) return ViewItem.from_component(component) @@ -895,6 +905,39 @@ def add_item(self, item: ViewItem[V]) -> Self: super().add_item(item) return self + def replace_item(self, original_item: ViewItem[V] | str | int, new_item: ViewItem[V]) -> Self: + """Directly replace an item in this view. + If an :class:`int` is provided, the item will be replaced by ``id``, otherwise by ``custom_id``. + + Parameters + ---------- + original_item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] + The item, item ``id``, or item ``custom_id`` to replace in the view. + new_item: :class:`ViewItem` + The new item to insert into the view. + + Returns + ------- + :class:`BaseView` + The view instance. + """ + + if isinstance(original_item, (str, int)): + original_item = self.get_item(original_item) + if not original_item: + raise ValueError(f"Could not find original_item in view.") + try: + if original_item.parent is self: + i = self.children.index(original_item) + new_item.parent = self + self.children[i] = new_item + original_item.parent = None + else: + original_item.parent.replace_item(original_item, new_item) + except ValueError: + raise ValueError(f"Could not find original_item in view.") + return self + def refresh(self, components: list[Component]): # Refreshes view data using discord's values # Assumes the components and items are identical From f757ac0deae179a75dcb53c15d0c2452e7487fbc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 01:04:03 +0000 Subject: [PATCH 002/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/action_row.py | 4 +++- discord/ui/container.py | 4 +++- discord/ui/file_upload.py | 4 +++- discord/ui/section.py | 4 +++- discord/ui/view.py | 8 +++++--- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index a27c130c63..76ba4f328e 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -164,7 +164,9 @@ def remove_item(self, item: ViewItem | str | int) -> Self: item.parent = None return self - def replace_item(self, original_item: ViewItem | str | int, new_item: ViewItem) -> Self: + def replace_item( + self, original_item: ViewItem | str | int, new_item: ViewItem + ) -> Self: """Directly replace an item in this row. If an :class:`int` is provided, the item will be replaced by ``id``, otherwise by ``custom_id``. diff --git a/discord/ui/container.py b/discord/ui/container.py index c0ab523db2..0b06ce85ef 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -176,7 +176,9 @@ def remove_item(self, item: ViewItem | str | int) -> Self: item.parent = None return self - def replace_item(self, original_item: ViewItem | str | int, new_item: ViewItem) -> Self: + def replace_item( + self, original_item: ViewItem | str | int, new_item: ViewItem + ) -> Self: """Directly replace an item in this container. If an :class:`int` is provided, the item will be replaced by ``id``, otherwise by ``custom_id``. diff --git a/discord/ui/file_upload.py b/discord/ui/file_upload.py index 95a76331bc..e7adf10799 100644 --- a/discord/ui/file_upload.py +++ b/discord/ui/file_upload.py @@ -160,7 +160,9 @@ def refresh_from_modal(self, interaction: Interaction, data: dict) -> None: ] @classmethod - def from_component(cls: type[FileUpload], component: FileUploadComponent) -> FileUpload: + def from_component( + cls: type[FileUpload], component: FileUploadComponent + ) -> FileUpload: return cls( custom_id=component.custom_id, diff --git a/discord/ui/section.py b/discord/ui/section.py index b08d09ce38..8f5d72027a 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -168,7 +168,9 @@ def remove_item(self, item: ViewItem | str | int) -> Self: item.parent = None return self - def replace_item(self, original_item: ViewItem | str | int, new_item: ViewItem) -> Self: + def replace_item( + self, original_item: ViewItem | str | int, new_item: ViewItem + ) -> Self: """Directly replace an item in this section. If an :class:`int` is provided, the item will be replaced by ``id``, otherwise by ``custom_id``. diff --git a/discord/ui/view.py b/discord/ui/view.py index 28af4fd793..c405b4971b 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -47,6 +47,8 @@ from ..components import Component from ..components import Container as ContainerComponent from ..components import FileComponent +from ..components import FileUpload as FileUploadComponent +from ..components import InputText as InputTextComponent from ..components import Label as LabelComponent from ..components import MediaGallery as MediaGalleryComponent from ..components import Section as SectionComponent @@ -54,8 +56,6 @@ from ..components import Separator as SeparatorComponent from ..components import TextDisplay as TextDisplayComponent from ..components import Thumbnail as ThumbnailComponent -from ..components import InputText as InputTextComponent -from ..components import FileUpload as FileUploadComponent from ..components import _component_factory from ..enums import ChannelType from ..utils import find @@ -905,7 +905,9 @@ def add_item(self, item: ViewItem[V]) -> Self: super().add_item(item) return self - def replace_item(self, original_item: ViewItem[V] | str | int, new_item: ViewItem[V]) -> Self: + def replace_item( + self, original_item: ViewItem[V] | str | int, new_item: ViewItem[V] + ) -> Self: """Directly replace an item in this view. If an :class:`int` is provided, the item will be replaced by ``id``, otherwise by ``custom_id``. From 56b5b463da993111ceb3eff8cba99d7f4a065dda Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Dec 2025 20:08:08 -0500 Subject: [PATCH 003/117] cls --- discord/ui/input_text.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index 9174e215f1..18a5008028 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -260,7 +260,7 @@ def refresh_from_modal( return self.refresh_state(data) @classmethod - def from_component(type[InputText], component: InputTextComponent) -> InputText: + def from_component(cls: type[InputText], component: InputTextComponent) -> InputText: return cls( style=component.style, From ec0ddfa27339b430bd1a56fece039f9f2167f91b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 01:08:36 +0000 Subject: [PATCH 004/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/input_text.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index 18a5008028..cba80566ea 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -260,7 +260,9 @@ def refresh_from_modal( return self.refresh_state(data) @classmethod - def from_component(cls: type[InputText], component: InputTextComponent) -> InputText: + def from_component( + cls: type[InputText], component: InputTextComponent + ) -> InputText: return cls( style=component.style, From deeed7441e16da8b923cbd5dbe73b8eaab62e448 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Dec 2025 13:08:01 -0500 Subject: [PATCH 005/117] rework underlying --- discord/colour.py | 12 ++++++ discord/embeds.py | 10 +---- discord/ui/action_row.py | 26 +++++++----- discord/ui/button.py | 62 ++++++++++++++++++--------- discord/ui/container.py | 48 ++++++++++++--------- discord/ui/file.py | 44 +++++++++++++------ discord/ui/file_upload.py | 41 ++++++++++++------ discord/ui/input_text.py | 60 ++++++++++++++++++-------- discord/ui/item.py | 27 ++++++++---- discord/ui/label.py | 34 +++++++++++---- discord/ui/media_gallery.py | 13 ++++-- discord/ui/section.py | 32 +++++++++----- discord/ui/select.py | 85 +++++++++++++++++++++++++------------ discord/ui/separator.py | 24 ++++++++--- discord/ui/text_display.py | 18 ++++++-- discord/ui/thumbnail.py | 39 +++++++++++++---- discord/ui/view.py | 8 ++-- 17 files changed, 398 insertions(+), 185 deletions(-) diff --git a/discord/colour.py b/discord/colour.py index 0ec77d5786..fc83c04915 100644 --- a/discord/colour.py +++ b/discord/colour.py @@ -118,6 +118,18 @@ def to_rgb(self) -> tuple[int, int, int]: """Returns an (r, g, b) tuple representing the colour.""" return self.r, self.g, self.b + @classmethod + def resolve_value(cls: type[CT], value: int | Colour | None) -> CT: + if value is None or isinstance(value, Colour): + return value + elif isinstance(value, int): + return cls(value=value) + else: + raise TypeError( + "Expected discord.Colour, int, or None but received" + f" {value.__class__.__name__} instead." + ) + @classmethod def from_rgb(cls: type[CT], r: int, g: int, b: int) -> CT: """Constructs a :class:`Colour` from an RGB tuple.""" diff --git a/discord/embeds.py b/discord/embeds.py index 81424050e2..77f0c70370 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -522,15 +522,7 @@ def colour(self) -> Colour | None: @colour.setter def colour(self, value: int | Colour | None): # type: ignore - if value is None or isinstance(value, Colour): - self._colour = value - elif isinstance(value, int): - self._colour = Colour(value=value) - else: - raise TypeError( - "Expected discord.Colour, int, or None but received" - f" {value.__class__.__name__} instead." - ) + self._colour = Colour.resolve_value(value) color = colour diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 76ba4f328e..15ed4ec3dd 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -95,11 +95,7 @@ def __init__( self.children: list[ViewItem] = [] - self._underlying = ActionRowComponent._raw_construct( - type=ComponentType.action_row, - id=id, - children=[], - ) + self._underlying = self._generate_underlying(id=id) for func in self.__row_children_items__: item: ViewItem = func.__discord_ui_model_type__( @@ -112,13 +108,23 @@ def __init__( self.add_item(i) def _add_component_from_item(self, item: ViewItem): - self._underlying.children.append(item._underlying) + self.underlying.children.append(item._generate_underlying()) def _set_components(self, items: list[ViewItem]): - self._underlying.children.clear() + self.underlying.children.clear() for item in items: self._add_component_from_item(item) + def _generate_underlying(self, id: int | None = None) -> ActionRowComponent: + row = ActionRowComponent._raw_construct( + type=ComponentType.action_row, + id=id or self.id, + children=[], + ) + for i in self.children: + row.children.append(i._generate_underlying()) + return row + def add_item(self, item: ViewItem) -> Self: """Adds an item to the action row. @@ -385,7 +391,7 @@ def is_persistent(self) -> bool: return all(item.is_persistent() for item in self.children) def refresh_component(self, component: ActionRowComponent) -> None: - self._underlying = component + self.underlying = component for i, y in enumerate(component.components): x = self.children[i] x.refresh_component(y) @@ -423,14 +429,14 @@ def width(self): """Return the sum of the items' widths.""" t = 0 for item in self.children: - t += 1 if item._underlying.type is ComponentType.button else 5 + t += 1 if item.underlying.type is ComponentType.button else 5 return t def walk_items(self) -> Iterator[ViewItem]: yield from self.children def to_component_dict(self) -> ActionRowPayload: - self._set_components(self.children) + self._underlying = self._generate_underlying() return super().to_component_dict() @classmethod diff --git a/discord/ui/button.py b/discord/ui/button.py index 8f1df340dd..857e933798 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -147,8 +147,7 @@ def __init__( f" {emoji.__class__}" ) - self._underlying = ButtonComponent._raw_construct( - type=ComponentType.button, + self._underlying = self._generate_underlying( custom_id=custom_id, url=url, disabled=disabled, @@ -160,14 +159,37 @@ def __init__( ) self.row = row + def _generate_underlying( + self, + style: ButtonStyle | None = None, + label: str | None = None, + disabled: bool = False, + custom_id: str | None = None, + url: str | None = None, + emoji: str | GuildEmoji | AppEmoji | PartialEmoji | None = None, + sku_id: int | None = None, + id: int | None = None, + ) -> ButtonComponent: + return ButtonComponent._raw_construct( + type=ComponentType.button, + custom_id=custom_id or self.custom_id, + url=url or self.url, + disabled=disabled or self.disabled, + label=label or self.label, + style=style or self.style, + emoji=emoji or self.emoji, + sku_id=sku_id or self.sku_id, + id=id or self.id, + ) + @property def style(self) -> ButtonStyle: """The style of the button.""" - return self._underlying.style + return self.underlying.style @style.setter def style(self, value: ButtonStyle): - self._underlying.style = value + self.underlying.style = value @property def custom_id(self) -> str | None: @@ -175,7 +197,7 @@ def custom_id(self) -> str | None: If this button is for a URL, it does not have a custom ID. """ - return self._underlying.custom_id + return self.underlying.custom_id @custom_id.setter def custom_id(self, value: str | None): @@ -183,53 +205,53 @@ def custom_id(self, value: str | None): raise TypeError("custom_id must be None or str") if value and len(value) > 100: raise ValueError("custom_id must be 100 characters or fewer") - self._underlying.custom_id = value + self.underlying.custom_id = value self._provided_custom_id = value is not None @property def url(self) -> str | None: """The URL this button sends you to.""" - return self._underlying.url + return self.underlying.url @url.setter def url(self, value: str | None): if value is not None and not isinstance(value, str): raise TypeError("url must be None or str") - self._underlying.url = value + self.underlying.url = value @property def disabled(self) -> bool: """Whether the button is disabled or not.""" - return self._underlying.disabled + return self.underlying.disabled @disabled.setter def disabled(self, value: bool): - self._underlying.disabled = bool(value) + self.underlying.disabled = bool(value) @property def label(self) -> str | None: """The label of the button, if available.""" - return self._underlying.label + return self.underlying.label @label.setter def label(self, value: str | None): if value and len(str(value)) > 80: raise ValueError("label must be 80 characters or fewer") - self._underlying.label = str(value) if value is not None else value + self.underlying.label = str(value) if value is not None else value @property def emoji(self) -> PartialEmoji | None: """The emoji of the button, if available.""" - return self._underlying.emoji + return self.underlying.emoji @emoji.setter def emoji(self, value: str | GuildEmoji | AppEmoji | PartialEmoji | None): # type: ignore if value is None: - self._underlying.emoji = None + self.underlying.emoji = None elif isinstance(value, str): - self._underlying.emoji = PartialEmoji.from_str(value) + self.underlying.emoji = PartialEmoji.from_str(value) elif isinstance(value, _EmojiTag): - self._underlying.emoji = value._to_partial() + self.underlying.emoji = value._to_partial() else: raise TypeError( "expected str, GuildEmoji, AppEmoji, or PartialEmoji, received" @@ -239,14 +261,14 @@ def emoji(self, value: str | GuildEmoji | AppEmoji | PartialEmoji | None): # ty @property def sku_id(self) -> int | None: """The ID of the SKU this button refers to.""" - return self._underlying.sku_id + return self.underlying.sku_id @sku_id.setter def sku_id(self, value: int | None): # type: ignore if value is None: - self._underlying.sku_id = None + self.underlying.sku_id = None elif isinstance(value, int): - self._underlying.sku_id = value + self.underlying.sku_id = value else: raise TypeError(f"expected int or None, received {value.__class__} instead") @@ -281,7 +303,7 @@ def is_persistent(self) -> bool: return super().is_persistent() def refresh_component(self, button: ButtonComponent) -> None: - self._underlying = button + self.underlying = button def button( diff --git a/discord/ui/container.py b/discord/ui/container.py index 0b06ce85ef..ab7590d8f5 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -108,25 +108,39 @@ def __init__( self.items: list[ViewItem] = [] - self._underlying = ContainerComponent._raw_construct( - type=ComponentType.container, + self._underlying = self._generate_underlying( id=id, - components=[], - accent_color=None, + accent_color=colour or color, spoiler=spoiler, ) - self.color = colour or color for i in items: self.add_item(i) def _add_component_from_item(self, item: ViewItem): - self._underlying.components.append(item._underlying) + self.underlying.components.append(item._generate_underlying()) def _set_components(self, items: list[ViewItem]): - self._underlying.components.clear() + self.underlying.components.clear() for item in items: self._add_component_from_item(item) + def _generate_underlying( + self, + color: int | Colour | None = None, + spoiler: bool = False, + id: int | None = None, + ) -> ContainerComponent: + container = ContainerComponent._raw_construct( + type=ComponentType.container, + id=id or self.id, + components=[], + accent_color=Colour.resolve_value(colour or color or self.colour), + spoiler=spoiler or self.spoiler, + ) + for i in self.items: + container.components.append(i._generate_underlying()) + return container + def add_item(self, item: ViewItem) -> Self: """Adds an item to the container. @@ -365,27 +379,19 @@ def copy_text(self) -> str: @property def spoiler(self) -> bool: """Whether the container has the spoiler overlay. Defaults to ``False``.""" - return self._underlying.spoiler + return self.underlying.spoiler @spoiler.setter def spoiler(self, spoiler: bool) -> None: - self._underlying.spoiler = spoiler + self.underlying.spoiler = spoiler @property def colour(self) -> Colour | None: - return self._underlying.accent_color + return self.underlying.accent_color @colour.setter def colour(self, value: int | Colour | None): # type: ignore - if value is None or isinstance(value, Colour): - self._underlying.accent_color = value - elif isinstance(value, int): - self._underlying.accent_color = Colour(value=value) - else: - raise TypeError( - "Expected discord.Colour, int, or None but received" - f" {value.__class__.__name__} instead." - ) + self.underlying.accent_color = Colour.resolve_value(value) color = colour @@ -396,7 +402,7 @@ def is_persistent(self) -> bool: return all(item.is_persistent() for item in self.items) def refresh_component(self, component: ContainerComponent) -> None: - self._underlying = component + self.underlying = component i = 0 for y in component.components: x = self.items[i] @@ -443,7 +449,7 @@ def walk_items(self) -> Iterator[ViewItem]: yield item def to_component_dict(self) -> ContainerComponentPayload: - self._set_components(self.items) + self._underlying = self._generate_underlying() return super().to_component_dict() @classmethod diff --git a/discord/ui/file.py b/discord/ui/file.py index 32e1ac2f45..523bf61c57 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -69,47 +69,65 @@ class File(ViewItem[V]): def __init__(self, url: str, *, spoiler: bool = False, id: int | None = None): super().__init__() - self.file = UnfurledMediaItem(url) + file = UnfurledMediaItem(url) - self._underlying = FileComponent._raw_construct( - type=ComponentType.file, + self._underlying = self._generate_underlying( id=id, - file=self.file, + file=file, spoiler=spoiler, ) + def _generate_underlying( + self, file: UnfurledMediaItem | None = None, spoiler: bool | None = None, id: int | None = None + ) -> FileComponent: + return FileComponent._raw_construct( + type=ComponentType.file, + id=id or self.id, + file=file or self.file, + spoiler=spoiler if spoiler is not None else self.spoiler, + ) + + @property + def file(self) -> UnfurledMediaItem: + """The file's unerlying media item.""" + return self.underlying.file + + @file.setter + def url(self, value: UnfurledMediaItem) -> None: + self.underlying.file = value + @property def url(self) -> str: - """The URL of this file's media. This must be an ``attachment://`` URL that references a :class:`~discord.File`.""" - return self._underlying.file and self._underlying.file.url + """The URL of this file's underlying media. This must be an ``attachment://`` URL that references a :class:`~discord.File`.""" + return self.underlying.file and self.underlying.file.url @url.setter def url(self, value: str) -> None: - self._underlying.file.url = value + self.underlying.file.url = value @property def spoiler(self) -> bool: """Whether the file has the spoiler overlay. Defaults to ``False``.""" - return self._underlying.spoiler + return self.underlying.spoiler @spoiler.setter def spoiler(self, spoiler: bool) -> None: - self._underlying.spoiler = spoiler + self.underlying.spoiler = spoiler @property def name(self) -> str: """The name of this file, if provided by Discord.""" - return self._underlying.name + return self.underlying.name @property def size(self) -> int: """The size of this file in bytes, if provided by Discord.""" - return self._underlying.size + return self.underlying.size def refresh_component(self, component: FileComponent) -> None: - original = self._underlying.file + original = self.underlying.file component.file._static_url = original._static_url - self._underlying = component + self.underlying = component def to_component_dict(self) -> FileComponentPayload: return super().to_component_dict() diff --git a/discord/ui/file_upload.py b/discord/ui/file_upload.py index e7adf10799..158f709493 100644 --- a/discord/ui/file_upload.py +++ b/discord/ui/file_upload.py @@ -66,7 +66,7 @@ def __init__( raise TypeError(f"required must be bool not {required.__class__.__name__}") # type: ignore custom_id = os.urandom(16).hex() if custom_id is None else custom_id - self._underlying: FileUploadComponent = FileUploadComponent._raw_construct( + self._underlying: FileUploadComponent = self._generate_underlying( type=ComponentType.file_upload, custom_id=custom_id, min_values=min_values, @@ -82,19 +82,36 @@ def __repr__(self) -> str: ) return f"<{self.__class__.__name__} {attrs}>" + def _generate_underlying( + self, + custom_id: str | None = None, + min_values: int | None = None, + max_values: int | None = None, + required: bool = None, + id: int | None = None, + ) -> FileUploadComponent: + return FileUploadComponent._raw_construct( + type=ComponentType.file_upload, + custom_id=custom_id or self.custom_id, + min_values=min_values or self.min_values, + max_values=max_values or self.max_values, + required=required if required is not None else self.required, + id=id or self.id, + ) + @property def type(self) -> ComponentType: - return self._underlying.type + return self.underlying.type @property def id(self) -> int | None: """The ID of this component. If not provided by the user, it is set sequentially by Discord.""" - return self._underlying.id + return self.underlying.id @property def custom_id(self) -> str: """The custom id that gets received during an interaction.""" - return self._underlying.custom_id + return self.underlying.custom_id @custom_id.setter def custom_id(self, value: str): @@ -102,12 +119,12 @@ def custom_id(self, value: str): raise TypeError( f"custom_id must be None or str not {value.__class__.__name__}" ) - self._underlying.custom_id = value + self.underlying.custom_id = value @property def min_values(self) -> int | None: """The minimum number of files that must be uploaded. Defaults to 0.""" - return self._underlying.min_values + return self.underlying.min_values @min_values.setter def min_values(self, value: int | None): @@ -115,12 +132,12 @@ def min_values(self, value: int | None): raise TypeError(f"min_values must be None or int not {value.__class__.__name__}") # type: ignore if value and (value < 0 or value > 10): raise ValueError("min_values must be between 0 and 10") - self._underlying.min_values = value + self.underlying.min_values = value @property def max_values(self) -> int | None: """The maximum number of files that can be uploaded.""" - return self._underlying.max_values + return self.underlying.max_values @max_values.setter def max_values(self, value: int | None): @@ -128,18 +145,18 @@ def max_values(self, value: int | None): raise TypeError(f"max_values must be None or int not {value.__class__.__name__}") # type: ignore if value and (value < 1 or value > 10): raise ValueError("max_values must be between 1 and 10") - self._underlying.max_values = value + self.underlying.max_values = value @property def required(self) -> bool: """Whether the input file upload is required or not. Defaults to ``True``.""" - return self._underlying.required + return self.underlying.required @required.setter def required(self, value: bool): if not isinstance(value, bool): raise TypeError(f"required must be bool not {value.__class__.__name__}") # type: ignore - self._underlying.required = bool(value) + self.underlying.required = bool(value) @property def values(self) -> list[Attachment] | None: @@ -147,7 +164,7 @@ def values(self) -> list[Attachment] | None: return self._attachments def to_component_dict(self) -> FileUploadComponentPayload: - return self._underlying.to_dict() + return self.underlying.to_dict() def refresh_from_modal(self, interaction: Interaction, data: dict) -> None: values = data.get("values", []) diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index cba80566ea..30949425f7 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -117,8 +117,7 @@ def __init__( ) custom_id = os.urandom(16).hex() if custom_id is None else custom_id - self._underlying = InputTextComponent._raw_construct( - type=ComponentType.input_text, + self._underlying = self._generate_underlying( style=style, custom_id=custom_id, label=label, @@ -139,10 +138,35 @@ def __repr__(self) -> str: ) return f"<{self.__class__.__name__} {attrs}>" + def _generate_underlying( + self, + style: InputTextStyle | None = None, + custom_id: str | None = None, + label: str | None = None, + placeholder: str | None = None, + min_length: int | None = None, + max_length: int | None = None, + required: bool | None = True, + value: str | None = None, + id: int | None = None, + ) -> InputTextComponent: + return InputTextComponent._raw_construct( + type=ComponentType.input_text, + style=style or self.style, + custom_id=custom_id or self.custom_id, + label=label or self.label, + placeholder=placeholder or self.placeholder, + min_length=min_length or self.min_length, + max_length=max_length or self.max_length, + required=required or self.required, + value=value or self.value, + id=id or self.id, + ) + @property def style(self) -> InputTextStyle: """The style of the input text field.""" - return self._underlying.style + return self.underlying.style @style.setter def style(self, value: InputTextStyle): @@ -150,12 +174,12 @@ def style(self, value: InputTextStyle): raise TypeError( f"style must be of type InputTextStyle not {value.__class__.__name__}" ) - self._underlying.style = value + self.underlying.style = value @property def custom_id(self) -> str: """The ID of the input text field that gets received during an interaction.""" - return self._underlying.custom_id + return self.underlying.custom_id @custom_id.setter def custom_id(self, value: str): @@ -163,12 +187,12 @@ def custom_id(self, value: str): raise TypeError( f"custom_id must be None or str not {value.__class__.__name__}" ) - self._underlying.custom_id = value + self.underlying.custom_id = value @property def label(self) -> str: """The label of the input text field.""" - return self._underlying.label + return self.underlying.label @label.setter def label(self, value: str): @@ -176,12 +200,12 @@ def label(self, value: str): raise TypeError(f"label should be str not {value.__class__.__name__}") if len(value) > 45: raise ValueError("label must be 45 characters or fewer") - self._underlying.label = value + self.underlying.label = value @property def placeholder(self) -> str | None: """The placeholder text that is shown before anything is entered, if any.""" - return self._underlying.placeholder + return self.underlying.placeholder @placeholder.setter def placeholder(self, value: str | None): @@ -189,12 +213,12 @@ def placeholder(self, value: str | None): raise TypeError(f"placeholder must be None or str not {value.__class__.__name__}") # type: ignore if value and len(value) > 100: raise ValueError("placeholder must be 100 characters or fewer") - self._underlying.placeholder = value + self.underlying.placeholder = value @property def min_length(self) -> int | None: """The minimum number of characters that must be entered. Defaults to 0.""" - return self._underlying.min_length + return self.underlying.min_length @min_length.setter def min_length(self, value: int | None): @@ -202,12 +226,12 @@ def min_length(self, value: int | None): raise TypeError(f"min_length must be None or int not {value.__class__.__name__}") # type: ignore if value and (value < 0 or value) > 4000: raise ValueError("min_length must be between 0 and 4000") - self._underlying.min_length = value + self.underlying.min_length = value @property def max_length(self) -> int | None: """The maximum number of characters that can be entered.""" - return self._underlying.max_length + return self.underlying.max_length @max_length.setter def max_length(self, value: int | None): @@ -215,18 +239,18 @@ def max_length(self, value: int | None): raise TypeError(f"min_length must be None or int not {value.__class__.__name__}") # type: ignore if value and (value <= 0 or value > 4000): raise ValueError("max_length must be between 1 and 4000") - self._underlying.max_length = value + self.underlying.max_length = value @property def required(self) -> bool | None: """Whether the input text field is required or not. Defaults to ``True``.""" - return self._underlying.required + return self.underlying.required @required.setter def required(self, value: bool | None): if not isinstance(value, bool): raise TypeError(f"required must be bool not {value.__class__.__name__}") # type: ignore - self._underlying.required = bool(value) + self.underlying.required = bool(value) @property def value(self) -> str | None: @@ -234,7 +258,7 @@ def value(self) -> str | None: if self._input_value is not False: # only False on init, otherwise the value was either set or cleared return self._input_value # type: ignore - return self._underlying.value + return self.underlying.value @value.setter def value(self, value: str | None): @@ -242,7 +266,7 @@ def value(self, value: str | None): raise TypeError(f"value must be None or str not {value.__class__.__name__}") # type: ignore if value and len(str(value)) > 4000: raise ValueError("value must be 4000 characters or fewer") - self._underlying.value = value + self.underlying.value = value @property def width(self) -> int: diff --git a/discord/ui/item.py b/discord/ui/item.py index 6f21c7684c..1b58d05de9 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -68,12 +68,12 @@ def __init__(self): self.parent: Item | ItemInterface | None = None def to_component_dict(self) -> dict[str, Any]: - if not self._underlying: + if not self.underlying: raise NotImplementedError - return self._underlying.to_dict() + return self.underlying.to_dict() def refresh_component(self, component: Component) -> None: - self._underlying = component + self.underlying = component def refresh_state(self, interaction: Interaction) -> None: return None @@ -82,11 +82,22 @@ def refresh_state(self, interaction: Interaction) -> None: def from_component(cls: type[I], component: Component) -> I: return cls() + @property + def underlying(self) -> Component: + return self._underlying + + @underlying.setter + def underlying(self, value: Component) -> None: + self._underlying = value + @property def type(self) -> ComponentType: - if not self._underlying: + if not self.underlying: raise NotImplementedError - return self._underlying.type + return self.underlying.type + + def _generate_underlying(self) -> Component: + raise NotImplementedError def is_dispatchable(self) -> bool: return False @@ -117,13 +128,13 @@ def id(self) -> int | None: Optional[:class:`int`] The ID of this item, or ``None`` if the user didn't set one. """ - return self._underlying and self._underlying.id + return self.underlying and self.underlying.id @id.setter def id(self, value) -> None: - if not self._underlying: + if not self.underlying: return - self._underlying.id = value + self.underlying.id = value class ViewItem(Item[V]): diff --git a/discord/ui/label.py b/discord/ui/label.py index 6de557a702..af74482392 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -95,10 +95,8 @@ def __init__( self.item: ModalItem = None - self._underlying = LabelComponent._raw_construct( - type=ComponentType.label, + self._underlying = self._generate_underlying( id=id, - component=None, label=label, description=description, ) @@ -113,7 +111,25 @@ def modal(self, value): self.item.modal = value def _set_component_from_item(self, item: ModalItem): - self._underlying.component = item._underlying + self.underlying.component = item._generate_underlying() + + def _generate_underlying( + self, + label: str | None = None, + description: str | None = None, + id: int | None = None, + ) -> LabelComponent: + label = LabelComponent._raw_construct( + type=ComponentType.label, + id=id or self.id, + component=None, + label=label or self.label, + description=description or self.description, + ) + + if self.item: + label.component = self.item._generate_underlying() + return label def set_item(self, item: ModalItem) -> Self: """Set this label's item. @@ -372,20 +388,20 @@ def set_file_upload( @property def label(self) -> str: """The label text. Must be 45 characters or fewer.""" - return self._underlying.label + return self.underlying.label @label.setter def label(self, value: str) -> None: - self._underlying.label = value + self.underlying.label = value @property def description(self) -> str | None: """The description for this label. Must be 100 characters or fewer.""" - return self._underlying.description + return self.underlying.description @description.setter def description(self, value: str | None) -> None: - self._underlying.description = value + self.underlying.description = value def is_dispatchable(self) -> bool: return self.item.is_dispatchable() @@ -394,7 +410,7 @@ def is_persistent(self) -> bool: return self.item.is_persistent() def refresh_component(self, component: LabelComponent) -> None: - self._underlying = component + self.underlying = component self.item.refresh_component(component.component) def walk_items(self) -> Iterator[ModalItem]: diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 838a837d36..9f3026dae7 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -65,13 +65,18 @@ class MediaGallery(ViewItem[V]): def __init__(self, *items: MediaGalleryItem, id: int | None = None): super().__init__() - self._underlying = MediaGalleryComponent._raw_construct( - type=ComponentType.media_gallery, id=id, items=[i for i in items] + self._underlying = self._generate_underlying( + id=id, items=items + ) + + def _generate_underlying(self, id: int | None = None, items: list[MediaGalleryItem] | None = None) -> MediaGalleryComponent: + return MediaGalleryComponent._raw_construct( + type=ComponentType.media_gallery, id=id or self.id, items=[i for i in items] if items else [] ) @property def items(self): - return self._underlying.items + return self.underlying.items def append_item(self, item: MediaGalleryItem) -> Self: """Adds a :attr:`MediaGalleryItem` to the gallery. @@ -95,7 +100,7 @@ def append_item(self, item: MediaGalleryItem) -> Self: if not isinstance(item, MediaGalleryItem): raise TypeError(f"expected MediaGalleryItem not {item.__class__!r}") - self._underlying.items.append(item) + self.underlying.items.append(item) return self def add_item( diff --git a/discord/ui/section.py b/discord/ui/section.py index 8f5d72027a..2356e67e38 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -93,11 +93,8 @@ def __init__( self.items: list[ViewItem] = [] self.accessory: ViewItem | None = None - self._underlying = SectionComponent._raw_construct( - type=ComponentType.section, + self._underlying = self._generate_underlying( id=id, - components=[], - accessory=None, ) for func in self.__section_accessory_item__: item: ViewItem = func.__discord_ui_model_type__( @@ -112,13 +109,28 @@ def __init__( self.add_item(i) def _add_component_from_item(self, item: ViewItem): - self._underlying.components.append(item._underlying) + self.underlying.components.append(item.underlying) def _set_components(self, items: list[ViewItem]): - self._underlying.components.clear() + self.underlying.components.clear() for item in items: self._add_component_from_item(item) + def _generate_underlying( + self, id: int | None = None + ) -> SectionComponent: + section = SectionComponent._raw_construct( + type=ComponentType.section, + id=id or self.id, + components=[], + accessory=None, + ) + for i in self.items: + section.components.append(i._generate_underlying()) + if self.accessory: + section.accessory = self.accessory._generate_underlying() + return section + def add_item(self, item: ViewItem) -> Self: """Adds an item to the section. @@ -264,7 +276,7 @@ def set_accessory(self, item: ViewItem) -> Self: item.parent = self self.accessory = item - self._underlying.accessory = item._underlying + self.underlying.accessory = item._generate_underlying() return self def set_thumbnail( @@ -308,7 +320,7 @@ def is_persistent(self) -> bool: return self.accessory.is_persistent() def refresh_component(self, component: SectionComponent) -> None: - self._underlying = component + self.underlying = component for x, y in zip(self.items, component.components): x.refresh_component(y) if self.accessory and component.accessory: @@ -356,9 +368,7 @@ def walk_items(self) -> Iterator[ViewItem]: yield from r def to_component_dict(self) -> SectionComponentPayload: - self._set_components(self.items) - if self.accessory: - self.set_accessory(self.accessory) + self._underlying = self._generate_underlying() return super().to_component_dict() @classmethod diff --git a/discord/ui/select.py b/discord/ui/select.py index 3969739fa4..f223af0edf 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -276,7 +276,7 @@ def __init__( self._provided_custom_id = custom_id is not None custom_id = os.urandom(16).hex() if custom_id is None else custom_id - self._underlying: SelectMenu = SelectMenu._raw_construct( + self._underlying: SelectMenu = self._generate_underlying( custom_id=custom_id, type=select_type, placeholder=placeholder, @@ -291,6 +291,35 @@ def __init__( ) self.row = row + def _generate_underlying( + self, + select_type: ComponentType | None = None, + custom_id: str | None = None, + placeholder: str | None = None, + min_values: int = None, + max_values: int = None, + options: list[SelectOption] | None = None, + channel_types: list[ChannelType] | None = None, + disabled: bool = None, + row: int | None = None, + id: int | None = None, + required: bool | None = None, + default_values: Sequence[SelectDefaultValue | ST] | None = None, + ) -> SelectMenu: + return SelectMenu._raw_construct( + custom_id=custom_id or self.custom_id, + type=select_type or self.select_type, + placeholder=placeholder or self.placeholder, + min_values=min_values if min_values is not None else self.min_values, + max_values=max_values if max_values is not None else self.max_values, + disabled=disabled if disabled is not None else self.disabled, + options=options or self.options, + channel_types=channel_types or self.channel_types, + id=id or self.id, + required=required if required is not None else self.required, + default_values=default_values or self.default_values, + ) + def _handle_default_values( self, default_values: Sequence[Snowflake | ST] | None, @@ -338,7 +367,7 @@ def _handle_default_values( @property def custom_id(self) -> str: """The ID of the select menu that gets received during an interaction.""" - return self._underlying.custom_id + return self.underlying.custom_id @custom_id.setter def custom_id(self, value: str): @@ -346,13 +375,13 @@ def custom_id(self, value: str): raise TypeError("custom_id must be None or str") if len(value) > 100: raise ValueError("custom_id must be 100 characters or fewer") - self._underlying.custom_id = value + self.underlying.custom_id = value self._provided_custom_id = value is not None @property def placeholder(self) -> str | None: """The placeholder text that is shown if nothing is selected, if any.""" - return self._underlying.placeholder + return self.underlying.placeholder @placeholder.setter def placeholder(self, value: str | None): @@ -361,74 +390,74 @@ def placeholder(self, value: str | None): if value and len(value) > 150: raise ValueError("placeholder must be 150 characters or fewer") - self._underlying.placeholder = value + self.underlying.placeholder = value @property def min_values(self) -> int: """The minimum number of items that must be chosen for this select menu.""" - return self._underlying.min_values + return self.underlying.min_values @min_values.setter def min_values(self, value: int): if value < 0 or value > 25: raise ValueError("min_values must be between 0 and 25") - self._underlying.min_values = int(value) + self.underlying.min_values = int(value) @property def max_values(self) -> int: """The maximum number of items that must be chosen for this select menu.""" - return self._underlying.max_values + return self.underlying.max_values @max_values.setter def max_values(self, value: int): if value < 1 or value > 25: raise ValueError("max_values must be between 1 and 25") - self._underlying.max_values = int(value) + self.underlying.max_values = int(value) @property def disabled(self) -> bool: """Whether the select is disabled or not.""" - return self._underlying.disabled + return self.underlying.disabled @property def required(self) -> bool: """Whether the select is required or not. Only applicable in modal selects.""" - return self._underlying.required + return self.underlying.required @required.setter def required(self, value: bool): - self._underlying.required = value + self.underlying.required = value @disabled.setter def disabled(self, value: bool): - self._underlying.disabled = bool(value) + self.underlying.disabled = bool(value) @property def channel_types(self) -> list[ChannelType]: """A list of channel types that can be selected in this menu.""" - return self._underlying.channel_types + return self.underlying.channel_types @channel_types.setter def channel_types(self, value: list[ChannelType]): - if self._underlying.type is not ComponentType.channel_select: + if self.underlying.type is not ComponentType.channel_select: raise InvalidArgument("channel_types can only be set on channel selects") - self._underlying.channel_types = value + self.underlying.channel_types = value @property def options(self) -> list[SelectOption]: """A list of options that can be selected in this menu.""" - return self._underlying.options + return self.underlying.options @options.setter def options(self, value: list[SelectOption]): - if self._underlying.type is not ComponentType.string_select: + if self.underlying.type is not ComponentType.string_select: raise InvalidArgument("options can only be set on string selects") if not isinstance(value, list): raise TypeError("options must be a list of SelectOption") if not all(isinstance(obj, SelectOption) for obj in value): raise TypeError("all list items must subclass SelectOption") - self._underlying.options = value + self.underlying.options = value @property def default_values(self) -> list[SelectDefaultValue]: @@ -437,14 +466,14 @@ def default_values(self) -> list[SelectDefaultValue]: .. versionadded:: 2.7 """ - return self._underlying.default_values + return self.underlying.default_values @default_values.setter def default_values( self, values: Sequence[SelectDefaultValue | Snowflake] | None ) -> None: default_values = self._handle_default_values(values, self.type) - self._underlying.default_values = default_values + self.underlying.default_values = default_values def add_default_value( self, @@ -553,7 +582,7 @@ def append_default_value( f"expected a SelectDefaultValue object, got {value.__class__.__name__}" ) - self._underlying.default_values.append(value) + self.underlying.default_values.append(value) return self def add_option( @@ -592,7 +621,7 @@ def add_option( ValueError The number of options exceeds 25. """ - if self._underlying.type is not ComponentType.string_select: + if self.underlying.type is not ComponentType.string_select: raise Exception("options can only be set on string selects") option = SelectOption( @@ -618,13 +647,13 @@ def append_option(self, option: SelectOption) -> Self: ValueError The number of options exceeds 25. """ - if self._underlying.type is not ComponentType.string_select: + if self.underlying.type is not ComponentType.string_select: raise Exception("options can only be set on string selects") - if len(self._underlying.options) > 25: + if len(self.underlying.options) > 25: raise ValueError("maximum number of options already provided") - self._underlying.options.append(option) + self.underlying.options.append(option) return self @property @@ -636,7 +665,7 @@ def values(self) -> list[ST]: if self._interaction is None or self._interaction.data is None: # The select has not been interacted with yet return [] - select_type = self._underlying.type + select_type = self.underlying.type if select_type is ComponentType.string_select: return self._selected_values # type: ignore # ST is str resolved = [] @@ -710,7 +739,7 @@ def to_component_dict(self) -> SelectMenuPayload: return super().to_component_dict() def refresh_component(self, component: SelectMenu) -> None: - self._underlying = component + self.underlying = component def refresh_state(self, interaction: Interaction | dict) -> None: data: ComponentInteractionData = ( diff --git a/discord/ui/separator.py b/discord/ui/separator.py index 2ddfae8af2..d3ce343173 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -72,30 +72,42 @@ def __init__( ): super().__init__() - self._underlying = SeparatorComponent._raw_construct( - type=ComponentType.separator, + self._underlying = self._generate_underlying( id=id, divider=divider, spacing=spacing, ) + def _generate_underlying( + self, + divider: bool | None = None, + spacing: SeparatorSpacingSize | None = None, + id: int | None = None, + ) -> SeparatorComponent: + return SeparatorComponent._raw_construct( + type=ComponentType.separator, + id=id or self.id, + divider=divider if divider is not None else self.divider, + spacing=spacing, + ) + @property def divider(self) -> bool: """Whether the separator is a divider. Defaults to ``True``.""" - return self._underlying.divider + return self.underlying.divider @divider.setter def divider(self, value: bool) -> None: - self._underlying.divider = value + self.underlying.divider = value @property def spacing(self) -> SeparatorSpacingSize: """The spacing size of the separator. Defaults to :attr:`~discord.SeparatorSpacingSize.small`.""" - return self._underlying.spacing + return self.underlying.spacing @spacing.setter def spacing(self, value: SeparatorSpacingSize) -> None: - self._underlying.spacing = value + self.underlying.spacing = value def to_component_dict(self) -> SeparatorComponentPayload: return super().to_component_dict() diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 76ab9dbc50..48cd2b658f 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -70,20 +70,30 @@ def __init__( ): super().__init__() - self._underlying = TextDisplayComponent._raw_construct( - type=ComponentType.text_display, + self._underlying = self._generate_underlying( id=id, content=content, ) + def _generate_underlying( + self, + content: str | None = None, + id: int | None = None, + ) -> TextDisplayComponent: + return TextDisplayComponent._raw_construct( + type=ComponentType.text_display, + id=id or self.id, + content=content or self.content, + ) + @property def content(self) -> str: """The text display's content.""" - return self._underlying.content + return self.underlying.content @content.setter def content(self, value: str) -> None: - self._underlying.content = value + self.underlying.content = value def to_component_dict(self) -> TextDisplayComponentPayload: return super().to_component_dict() diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index 61c44bd2ce..99a04e27d9 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -78,41 +78,64 @@ def __init__( media = UnfurledMediaItem(url) - self._underlying = ThumbnailComponent._raw_construct( - type=ComponentType.thumbnail, + self._underlying = self._generate_underlying( id=id, media=media, description=description, spoiler=spoiler, ) + def _generate_underlying( + self, + media: UnfurledMediaItem | None = None, + description: str | None = None, + spoiler: bool | None = False, + id: int | None = None, + ) -> ThumbnailComponent: + return ThumbnailComponent._raw_construct( + type=ComponentType.thumbnail, + id=id or self.id, + media=media or self.media, + description=description or self.description, + spoiler=spoiler if spoiler is not None else self.spoiler, + ) + + @property + def media(self) -> UnfurledMediaItem: + """The thumbnail's unerlying media item.""" + return self.underlying.media + + @media.setter + def url(self, value: UnfurledMediaItem) -> None: + self.underlying.media = value + @property def url(self) -> str: """The URL of this thumbnail's media. This can either be an arbitrary URL or an ``attachment://`` URL.""" - return self._underlying.media and self._underlying.media.url + return self.underlying.media and self.underlying.media.url @url.setter def url(self, value: str) -> None: - self._underlying.media.url = value + self.underlying.media.url = value @property def description(self) -> str | None: """The thumbnail's description, up to 1024 characters.""" - return self._underlying.description + return self.underlying.description @description.setter def description(self, description: str | None) -> None: - self._underlying.description = description + self.underlying.description = description @property def spoiler(self) -> bool: """Whether the thumbnail has the spoiler overlay. Defaults to ``False``.""" - return self._underlying.spoiler + return self.underlying.spoiler @spoiler.setter def spoiler(self, spoiler: bool) -> None: - self._underlying.spoiler = spoiler + self.underlying.spoiler = spoiler def to_component_dict(self) -> ThumbnailComponentPayload: return super().to_component_dict() diff --git a/discord/ui/view.py b/discord/ui/view.py index c405b4971b..d31a53dda6 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -372,7 +372,7 @@ def is_components_v2(self) -> bool: A view containing V2 components cannot be sent alongside message content or embeds. """ - return any([item._underlying.is_v2() for item in self.children]) + return any([item.underlying.is_v2() for item in self.children]) async def _scheduled_task(self, item: ViewItem[V], interaction: Interaction): try: @@ -700,11 +700,11 @@ def add_item(self, item: ViewItem[V]) -> Self: or the row the item is trying to be added to is full. """ - if item._underlying.is_v2(): + if item.underlying.is_v2(): raise ValueError( f"cannot use V2 components in View. Use DesignerView instead." ) - if isinstance(item._underlying, ActionRowComponent): + if isinstance(item.underlying, ActionRowComponent): for i in item.children: self.add_item(i) return self @@ -897,7 +897,7 @@ def add_item(self, item: ViewItem[V]) -> Self: Maximum number of items has been exceeded (40) """ - if isinstance(item._underlying, (SelectComponent, ButtonComponent)): + if isinstance(item.underlying, (SelectComponent, ButtonComponent)): raise ValueError( f"cannot add Select or Button to DesignerView directly. Use ActionRow instead." ) From 130fab83b1e8ee5c4ccb01394fc0489ba33c9c7b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 18:09:00 +0000 Subject: [PATCH 006/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/file.py | 5 ++++- discord/ui/media_gallery.py | 12 +++++++----- discord/ui/section.py | 4 +--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/discord/ui/file.py b/discord/ui/file.py index 523bf61c57..5826b6872f 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -78,7 +78,10 @@ def __init__(self, url: str, *, spoiler: bool = False, id: int | None = None): ) def _generate_underlying( - self, file: UnfurledMediaItem | None = None, spoiler: bool | None = None, id: int | None = None + self, + file: UnfurledMediaItem | None = None, + spoiler: bool | None = None, + id: int | None = None, ) -> FileComponent: return FileComponent._raw_construct( type=ComponentType.file, diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 9f3026dae7..e1b243775e 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -65,13 +65,15 @@ class MediaGallery(ViewItem[V]): def __init__(self, *items: MediaGalleryItem, id: int | None = None): super().__init__() - self._underlying = self._generate_underlying( - id=id, items=items - ) + self._underlying = self._generate_underlying(id=id, items=items) - def _generate_underlying(self, id: int | None = None, items: list[MediaGalleryItem] | None = None) -> MediaGalleryComponent: + def _generate_underlying( + self, id: int | None = None, items: list[MediaGalleryItem] | None = None + ) -> MediaGalleryComponent: return MediaGalleryComponent._raw_construct( - type=ComponentType.media_gallery, id=id or self.id, items=[i for i in items] if items else [] + type=ComponentType.media_gallery, + id=id or self.id, + items=[i for i in items] if items else [], ) @property diff --git a/discord/ui/section.py b/discord/ui/section.py index 2356e67e38..d6777bd01b 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -116,9 +116,7 @@ def _set_components(self, items: list[ViewItem]): for item in items: self._add_component_from_item(item) - def _generate_underlying( - self, id: int | None = None - ) -> SectionComponent: + def _generate_underlying(self, id: int | None = None) -> SectionComponent: section = SectionComponent._raw_construct( type=ComponentType.section, id=id or self.id, From 45da8fe344f266f210a958833c7649c7db3667de Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Dec 2025 13:21:44 -0500 Subject: [PATCH 007/117] maybe fixed --- discord/components.py | 2 +- discord/ui/action_row.py | 1 + discord/ui/button.py | 1 + discord/ui/container.py | 5 +++-- discord/ui/file.py | 1 + discord/ui/file_upload.py | 1 + discord/ui/input_text.py | 1 + discord/ui/item.py | 6 ++++-- discord/ui/label.py | 1 + discord/ui/media_gallery.py | 1 + discord/ui/section.py | 1 + discord/ui/select.py | 1 + discord/ui/separator.py | 1 + discord/ui/text_display.py | 1 + discord/ui/thumbnail.py | 1 + 15 files changed, 20 insertions(+), 5 deletions(-) diff --git a/discord/components.py b/discord/components.py index 9775c97f01..b0672c0296 100644 --- a/discord/components.py +++ b/discord/components.py @@ -134,7 +134,7 @@ def _raw_construct(cls: type[C], **kwargs) -> C: try: value = kwargs[slot] except KeyError: - pass + setattr(self, slot, None) else: setattr(self, slot, value) return self diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 15ed4ec3dd..9f34b80c9b 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -116,6 +116,7 @@ def _set_components(self, items: list[ViewItem]): self._add_component_from_item(item) def _generate_underlying(self, id: int | None = None) -> ActionRowComponent: + super()._generate_underlying(ActionRowComponent) row = ActionRowComponent._raw_construct( type=ComponentType.action_row, id=id or self.id, diff --git a/discord/ui/button.py b/discord/ui/button.py index 857e933798..da0500df9e 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -170,6 +170,7 @@ def _generate_underlying( sku_id: int | None = None, id: int | None = None, ) -> ButtonComponent: + super()._generate_underlying(ButtonComponent) return ButtonComponent._raw_construct( type=ComponentType.button, custom_id=custom_id or self.custom_id, diff --git a/discord/ui/container.py b/discord/ui/container.py index ab7590d8f5..b8364a20ab 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -126,15 +126,16 @@ def _set_components(self, items: list[ViewItem]): def _generate_underlying( self, - color: int | Colour | None = None, + accent_color: int | Colour | None = None, spoiler: bool = False, id: int | None = None, ) -> ContainerComponent: + super()._generate_underlying(ContainerComponent) container = ContainerComponent._raw_construct( type=ComponentType.container, id=id or self.id, components=[], - accent_color=Colour.resolve_value(colour or color or self.colour), + accent_color=Colour.resolve_value(accent_color self.colour), spoiler=spoiler or self.spoiler, ) for i in self.items: diff --git a/discord/ui/file.py b/discord/ui/file.py index 5826b6872f..17e57c87b1 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -83,6 +83,7 @@ def _generate_underlying( spoiler: bool | None = None, id: int | None = None, ) -> FileComponent: + super()._generate_underlying(FileComponent) return FileComponent._raw_construct( type=ComponentType.file, id=id or self.id, diff --git a/discord/ui/file_upload.py b/discord/ui/file_upload.py index 158f709493..6ef3205eaa 100644 --- a/discord/ui/file_upload.py +++ b/discord/ui/file_upload.py @@ -90,6 +90,7 @@ def _generate_underlying( required: bool = None, id: int | None = None, ) -> FileUploadComponent: + super()._generate_underlying(FileUploadComponent) return FileUploadComponent._raw_construct( type=ComponentType.file_upload, custom_id=custom_id or self.custom_id, diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index 30949425f7..920e15e4a0 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -150,6 +150,7 @@ def _generate_underlying( value: str | None = None, id: int | None = None, ) -> InputTextComponent: + super()._generate_underlying(InputTextComponent) return InputTextComponent._raw_construct( type=ComponentType.input_text, style=style or self.style, diff --git a/discord/ui/item.py b/discord/ui/item.py index 1b58d05de9..a38148bb19 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -96,8 +96,10 @@ def type(self) -> ComponentType: raise NotImplementedError return self.underlying.type - def _generate_underlying(self) -> Component: - raise NotImplementedError + def _generate_underlying(self, cls: type[Component]) -> Component: + if not self._underlying: + self._underlying = cls._raw_construct() + return self._underlying def is_dispatchable(self) -> bool: return False diff --git a/discord/ui/label.py b/discord/ui/label.py index 45617b54ae..919a2aa1e2 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -119,6 +119,7 @@ def _generate_underlying( description: str | None = None, id: int | None = None, ) -> LabelComponent: + super()._generate_underlying(LabelComponent) label = LabelComponent._raw_construct( type=ComponentType.label, id=id or self.id, diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index e1b243775e..4054fc782b 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -70,6 +70,7 @@ def __init__(self, *items: MediaGalleryItem, id: int | None = None): def _generate_underlying( self, id: int | None = None, items: list[MediaGalleryItem] | None = None ) -> MediaGalleryComponent: + super()._generate_underlying(MediaGalleryComponent) return MediaGalleryComponent._raw_construct( type=ComponentType.media_gallery, id=id or self.id, diff --git a/discord/ui/section.py b/discord/ui/section.py index d6777bd01b..5714c6b9eb 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -117,6 +117,7 @@ def _set_components(self, items: list[ViewItem]): self._add_component_from_item(item) def _generate_underlying(self, id: int | None = None) -> SectionComponent: + super()._generate_underlying(SectionComponent) section = SectionComponent._raw_construct( type=ComponentType.section, id=id or self.id, diff --git a/discord/ui/select.py b/discord/ui/select.py index 2d30061275..86511dfbcb 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -306,6 +306,7 @@ def _generate_underlying( required: bool | None = None, default_values: Sequence[SelectDefaultValue | ST] | None = None, ) -> SelectMenu: + super()._generate_underlying(SelectMenu) return SelectMenu._raw_construct( custom_id=custom_id or self.custom_id, type=select_type or self.select_type, diff --git a/discord/ui/separator.py b/discord/ui/separator.py index d3ce343173..35882e36ed 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -84,6 +84,7 @@ def _generate_underlying( spacing: SeparatorSpacingSize | None = None, id: int | None = None, ) -> SeparatorComponent: + super()._generate_underlying(SeparatorComponent) return SeparatorComponent._raw_construct( type=ComponentType.separator, id=id or self.id, diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 48cd2b658f..be5cc83324 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -80,6 +80,7 @@ def _generate_underlying( content: str | None = None, id: int | None = None, ) -> TextDisplayComponent: + super()._generate_underlying(TextDisplayComponent) return TextDisplayComponent._raw_construct( type=ComponentType.text_display, id=id or self.id, diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index 99a04e27d9..648efef735 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -92,6 +92,7 @@ def _generate_underlying( spoiler: bool | None = False, id: int | None = None, ) -> ThumbnailComponent: + super()._generate_underlying(ThumbnailComponent) return ThumbnailComponent._raw_construct( type=ComponentType.thumbnail, id=id or self.id, From aadf0edee772c88b67c6526863108d2f62a13bf3 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Dec 2025 13:23:11 -0500 Subject: [PATCH 008/117] , --- discord/ui/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index b8364a20ab..34c5e79145 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -135,7 +135,7 @@ def _generate_underlying( type=ComponentType.container, id=id or self.id, components=[], - accent_color=Colour.resolve_value(accent_color self.colour), + accent_color=Colour.resolve_value(accent_color, self.colour), spoiler=spoiler or self.spoiler, ) for i in self.items: From 3289505f3de8dc95ee8f361763350c6818e4acc5 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Dec 2025 13:25:41 -0500 Subject: [PATCH 009/117] or --- discord/ui/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 34c5e79145..de35f67d12 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -135,7 +135,7 @@ def _generate_underlying( type=ComponentType.container, id=id or self.id, components=[], - accent_color=Colour.resolve_value(accent_color, self.colour), + accent_color=Colour.resolve_value(accent_color or self.colour), spoiler=spoiler or self.spoiler, ) for i in self.items: From d984274c5838159ba1ebbc0c73a698d1060ea3db Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Dec 2025 13:28:53 -0500 Subject: [PATCH 010/117] spacing --- discord/ui/separator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/separator.py b/discord/ui/separator.py index 35882e36ed..9277211f84 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -89,7 +89,7 @@ def _generate_underlying( type=ComponentType.separator, id=id or self.id, divider=divider if divider is not None else self.divider, - spacing=spacing, + spacing=spacing or self.spacing, ) @property From 97db5d459eb75ba2234f543c38752e1045fc271c Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Dec 2025 13:51:49 -0500 Subject: [PATCH 011/117] replace and remove on gallery --- discord/ui/action_row.py | 3 +++ discord/ui/media_gallery.py | 36 +++++++++++++++++++++++++++++++++++- discord/ui/section.py | 3 +++ discord/ui/select.py | 4 ++-- discord/ui/view.py | 3 +++ 5 files changed, 46 insertions(+), 3 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 9f34b80c9b..2fb53bba8a 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -185,6 +185,9 @@ def replace_item( The new item to insert into the row. """ + if not isinstance(new_item, (Select, Button)): + raise TypeError(f"expected Select or Button, not {new_item.__class__!r}") + if isinstance(original_item, (str, int)): original_item = self.get_item(original_item) if not original_item: diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 4054fc782b..0d6df19d20 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -74,7 +74,7 @@ def _generate_underlying( return MediaGalleryComponent._raw_construct( type=ComponentType.media_gallery, id=id or self.id, - items=[i for i in items] if items else [], + items=[i for i in items] if items else [i for i in self.items or []], ) @property @@ -137,7 +137,41 @@ def add_item( return self.append_item(item) + def remove_item(self, index: int) -> Self: + """Removes an item from the gallery. + + Parameters + ---------- + index: :class:`int` + The index of the item to remove from the gallery. + """ + + try: + self.items.pop(item) + except IndexError: + pass + return self + + def replace_item( + self, index: int, new_item: MediaGalleryItem + ) -> Self: + """Directly replace an item in this gallery by index. + + Parameters + ---------- + original_item: :class:`int` + The index of the item to replace in this gallery. + new_item: :class:`MediaGalleryItem` + The new item to insert into the gallery. + """ + + if not isinstance(new_item, MediaGalleryItem): + raise TypeError(f"expected MediaGalleryItem not {new_item.__class__!r}") + self.items[i] = new_item + return self + def to_component_dict(self) -> MediaGalleryComponentPayload: + self.underlying = self._generate_underlying() return super().to_component_dict() @classmethod diff --git a/discord/ui/section.py b/discord/ui/section.py index 5714c6b9eb..a2d62da642 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -193,6 +193,9 @@ def replace_item( The new item to insert into the section. """ + if not isinstance(new_item, ViewItem): + raise TypeError(f"expected ViewItem not {new_item.__class__!r}") + if isinstance(original_item, (str, int)): original_item = self.get_item(original_item) if not original_item: diff --git a/discord/ui/select.py b/discord/ui/select.py index 86511dfbcb..9e0a21fc00 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -314,8 +314,8 @@ def _generate_underlying( min_values=min_values if min_values is not None else self.min_values, max_values=max_values if max_values is not None else self.max_values, disabled=disabled if disabled is not None else self.disabled, - options=options or self.options, - channel_types=channel_types or self.channel_types, + options=options if options is not None else self.options, + channel_types=channel_types if channel_types is not None else self.channel_types, id=id or self.id, required=required if required is not None else self.required, default_values=default_values or self.default_values, diff --git a/discord/ui/view.py b/discord/ui/view.py index d31a53dda6..edd8b9ef81 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -924,6 +924,9 @@ def replace_item( The view instance. """ + if not isinstance(new_item, ViewItem): + raise TypeError(f"expected ViewItem not {new_item.__class__!r}") + if isinstance(original_item, (str, int)): original_item = self.get_item(original_item) if not original_item: From 377526242884aefacca60901524ce37fc4da44c6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 18:52:17 +0000 Subject: [PATCH 012/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/media_gallery.py | 4 +--- discord/ui/select.py | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 0d6df19d20..b18e1e4069 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -152,9 +152,7 @@ def remove_item(self, index: int) -> Self: pass return self - def replace_item( - self, index: int, new_item: MediaGalleryItem - ) -> Self: + def replace_item(self, index: int, new_item: MediaGalleryItem) -> Self: """Directly replace an item in this gallery by index. Parameters diff --git a/discord/ui/select.py b/discord/ui/select.py index 9e0a21fc00..e2e4c53aad 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -315,7 +315,9 @@ def _generate_underlying( max_values=max_values if max_values is not None else self.max_values, disabled=disabled if disabled is not None else self.disabled, options=options if options is not None else self.options, - channel_types=channel_types if channel_types is not None else self.channel_types, + channel_types=( + channel_types if channel_types is not None else self.channel_types + ), id=id or self.id, required=required if required is not None else self.required, default_values=default_values or self.default_values, From a5d076ce214557c5aae38f8573ba33fe4317724e Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Dec 2025 13:55:47 -0500 Subject: [PATCH 013/117] index --- discord/ui/media_gallery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index b18e1e4069..a81873677e 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -147,7 +147,7 @@ def remove_item(self, index: int) -> Self: """ try: - self.items.pop(item) + self.items.pop(index) except IndexError: pass return self @@ -165,7 +165,7 @@ def replace_item(self, index: int, new_item: MediaGalleryItem) -> Self: if not isinstance(new_item, MediaGalleryItem): raise TypeError(f"expected MediaGalleryItem not {new_item.__class__!r}") - self.items[i] = new_item + self.items[index] = new_item return self def to_component_dict(self) -> MediaGalleryComponentPayload: From 22aaeb1b421329a4f406b10759ade2064510466a Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:01:06 -0500 Subject: [PATCH 014/117] select_type --- discord/ui/select.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/select.py b/discord/ui/select.py index e2e4c53aad..5d8bb41d13 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -293,7 +293,7 @@ def __init__( def _generate_underlying( self, - select_type: ComponentType | None = None, + type: ComponentType | None = None, custom_id: str | None = None, placeholder: str | None = None, min_values: int = None, From d06f3645d3bb92a5baa7c3377aac70431bcc47c8 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:01:42 -0500 Subject: [PATCH 015/117] row --- discord/ui/select.py | 1 - 1 file changed, 1 deletion(-) diff --git a/discord/ui/select.py b/discord/ui/select.py index 5d8bb41d13..60e24474e6 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -301,7 +301,6 @@ def _generate_underlying( options: list[SelectOption] | None = None, channel_types: list[ChannelType] | None = None, disabled: bool = None, - row: int | None = None, id: int | None = None, required: bool | None = None, default_values: Sequence[SelectDefaultValue | ST] | None = None, From 6906ae5c038bcbed2309c038c3d78730052c0c58 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:07:29 -0500 Subject: [PATCH 016/117] type --- discord/ui/select.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/select.py b/discord/ui/select.py index 60e24474e6..4e19f45240 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -308,7 +308,7 @@ def _generate_underlying( super()._generate_underlying(SelectMenu) return SelectMenu._raw_construct( custom_id=custom_id or self.custom_id, - type=select_type or self.select_type, + type=type or self.type, placeholder=placeholder or self.placeholder, min_values=min_values if min_values is not None else self.min_values, max_values=max_values if max_values is not None else self.max_values, From d993efe5901f6beea52d6c04e0a1d697cecabad1 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Wed, 24 Dec 2025 01:21:20 -0500 Subject: [PATCH 017/117] cl --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39840869c4..cac47785b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ These changes are available on the `master` branch, but have not yet been releas ([#2956](https://github.com/Pycord-Development/pycord/pull/2956)) - Added `Guild.fetch_roles_member_counts` method and `GuildRoleCounts` class. ([#3020](https://github.com/Pycord-Development/pycord/pull/3020)) +- Added `replace_item` to `DesignerView`, `Section`, `Container`, `ActionRow`, & `MediaGallery` + ([#3032](https://github.com/Pycord-Development/pycord/pull/3032)) ### Changed @@ -33,6 +35,8 @@ These changes are available on the `master` branch, but have not yet been releas TypeVars. ([#3002](https://github.com/Pycord-Development/pycord/pull/3002)) - Fixed `View`'s `disable_on_timeout` not working in private (DM) channels. ([#3016](https://github.com/Pycord-Development/pycord/pull/3016)) +- Fixed core issues with modifying items in `Container` and `Section` + ([#3032](https://github.com/Pycord-Development/pycord/pull/3032)) ### Deprecated From 3fc8b2aeca89c4bce1b6aeb3b5554e9cec40ca66 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 06:21:49 +0000 Subject: [PATCH 018/117] style(pre-commit): auto fixes from pre-commit.com hooks --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cac47785b0..594c9c73fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,8 @@ These changes are available on the `master` branch, but have not yet been releas ([#2956](https://github.com/Pycord-Development/pycord/pull/2956)) - Added `Guild.fetch_roles_member_counts` method and `GuildRoleCounts` class. ([#3020](https://github.com/Pycord-Development/pycord/pull/3020)) -- Added `replace_item` to `DesignerView`, `Section`, `Container`, `ActionRow`, & `MediaGallery` - ([#3032](https://github.com/Pycord-Development/pycord/pull/3032)) +- Added `replace_item` to `DesignerView`, `Section`, `Container`, `ActionRow`, & + `MediaGallery` ([#3032](https://github.com/Pycord-Development/pycord/pull/3032)) ### Changed From cb403d2958ae5b24e1143dd104c979da1e6a34d5 Mon Sep 17 00:00:00 2001 From: Lala Sabathil Date: Wed, 24 Dec 2025 16:31:31 +0100 Subject: [PATCH 019/117] fix(actions): rework release workflow (#3034) * fix(actions): rework release workflow * style(pre-commit): auto fixes from pre-commit.com hooks --------- Co-Authored-By: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 102 ++++++------- scripts/manage_rtd_version.py | 116 +++++++++++++++ scripts/notify_discord.py | 97 +++++++++++++ scripts/trigger_rtd_localizations.py | 75 ++++++++++ scripts/update_changelog.py | 205 +++++++++++++++++++++++++++ 5 files changed, 535 insertions(+), 60 deletions(-) create mode 100644 scripts/manage_rtd_version.py create mode 100644 scripts/notify_discord.py create mode 100644 scripts/trigger_rtd_localizations.py create mode 100644 scripts/update_changelog.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76065ffeee..2bafcaea9a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,7 @@ jobs: is_rc: ${{ steps.determine_vars.outputs.is_rc }} version: ${{ steps.determine_vars.outputs.version }} previous_tag: ${{ steps.determine_vars.outputs.previous_tag }} + previous_final_tag: ${{ steps.determine_vars.outputs.previous_final_tag }} runs-on: ubuntu-latest steps: - name: "Checkout Repository" @@ -41,18 +42,24 @@ jobs: env: VERSION: ${{ github.event.inputs.version }} run: | - VALID_VERSION_REGEX='^([0-9]+\.[0-9]+\.[0-9]+((a|b|rc|\.dev|\.post)[0-9]+)?)$' + set -euo pipefail + VALID_VERSION_REGEX='^[0-9]+\.[0-9]+\.[0-9]+(rc[0-9]+)?$' if ! [[ $VERSION =~ $VALID_VERSION_REGEX ]]; then - echo "::error::Invalid version string '$VERSION'. Must match PEP 440 (e.g. 1.2.0, 1.2.0rc1, 1.2.0.dev1, 1.2.0a1, 1.2.0b1, 1.2.0.post1)" + echo "::error::Invalid version string '$VERSION'. Only releases like 1.2.3 and release candidates like 1.2.3rc1 are supported." exit 1 fi - if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+rc[0-9]+$ ]]; then - echo "::error::Unsupported version string '$VERSION'. Only normal releases (e.g. 1.2.3) and rc (e.g. 1.2.3rc1) are supported at this time." + echo "version=$VERSION" >> $GITHUB_OUTPUT + PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || git describe --tags --abbrev=0 2>/dev/null || true) + if [[ -z "$PREVIOUS_TAG" ]]; then + echo "::error::Could not determine previous tag. Ensure at least one tag exists." exit 1 fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^) echo "previous_tag=${PREVIOUS_TAG}" >> $GITHUB_OUTPUT + PREVIOUS_FINAL_TAG=$(git tag --sort=-v:refname | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true) + if [[ -z "$PREVIOUS_FINAL_TAG" ]]; then + PREVIOUS_FINAL_TAG=$PREVIOUS_TAG + fi + echo "previous_final_tag=${PREVIOUS_FINAL_TAG}" >> $GITHUB_OUTPUT MAJOR_MINOR_VERSION=$(echo $VERSION | grep -oE '^[0-9]+\.[0-9]+') echo "branch_name=v${MAJOR_MINOR_VERSION}.x" >> $GITHUB_OUTPUT if [[ $VERSION == *rc* ]]; then @@ -146,6 +153,8 @@ jobs: shell: bash env: VERSION: ${{ inputs.version }} + PREVIOUS_TAG: ${{ needs.pre_config.outputs.previous_tag }} + PREVIOUS_FINAL_TAG: ${{ needs.pre_config.outputs.previous_final_tag }} REPOSITORY: ${{ github.repository }} GITHUB_TOKEN: ${{ secrets.ADMIN_GITHUB_TOKEN }} BRANCH: ${{ github.ref_name }} @@ -153,10 +162,14 @@ jobs: git config user.name "NyuwBot" git config user.email "nyuw@aitsys.dev" DATE=$(date +'%Y-%m-%d') - sed -i "/These changes are available on the \`.*\` branch, but have not yet been released\./{N;d;}" CHANGELOG.md - sed -i "s/## \[Unreleased\]/## [$VERSION] - $DATE/" CHANGELOG.md - sed -i "0,/## \[$VERSION\]/ s|## \[$VERSION\]|## [Unreleased]\n\nThese changes are available on the \`$BRANCH\` branch, but have not yet been released.\n\n### Added\n\n### Changed\n\n### Fixed\n\n### Deprecated\n\n### Removed\n\n&|" CHANGELOG.md - sed -i "s|\[unreleased\]:.*|[unreleased]: https://github.com/$REPOSITORY/compare/v$VERSION...HEAD\n[$VERSION]: https://github.com/$REPOSITORY/compare/$(git describe --tags --abbrev=0 @^)...v$VERSION|" CHANGELOG.md + python scripts/update_changelog.py \ + --path CHANGELOG.md \ + --version "$VERSION" \ + --previous-tag "$PREVIOUS_TAG" \ + --previous-final-tag "$PREVIOUS_FINAL_TAG" \ + --branch "$BRANCH" \ + --repository "$REPOSITORY" \ + --date "$DATE" git add CHANGELOG.md git commit -m "chore(release): update CHANGELOG.md for version $VERSION" - name: "Commit and Push Changelog to ${{ github.ref_name }}" @@ -235,34 +248,22 @@ jobs: docs_release: runs-on: ubuntu-latest needs: [lib_release, pre_config] - if: - ${{ needs.pre_config.outputs.is_rc == 'false' || (needs.pre_config.outputs.is_rc - == 'true' && endsWith(needs.pre_config.outputs.version, '0rc1')) }} environment: release steps: - - name: "Sync Versions on Read the Docs" - run: | - curl --location --request POST 'https://readthedocs.org/api/v3/projects/pycord/sync-versions/' \ - --header 'Content-Type: application/json' \ - --header "Authorization: Token ${{ secrets.READTHEDOCS_TOKEN }}" + - name: "Checkout repository" + uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true - - name: "Activate and Show Version on Read the Docs" + - name: "Sync and activate version on Read the Docs" + env: + READTHEDOCS_TOKEN: ${{ secrets.READTHEDOCS_TOKEN }} run: | - VERSION=${{ needs.pre_config.outputs.version }} - MAJOR_MINOR_VERSION=$(echo $VERSION | grep -oE '^[0-9]+\.[0-9]+') - HIDDEN=$([[ $VERSION == *rc* ]] && echo true || echo false) - if [[ $VERSION == *rc* ]]; then - DOCS_VERSION="v${MAJOR_MINOR_VERSION}.x" - else - DOCS_VERSION="v$VERSION" - fi - curl --location --request PATCH "https://readthedocs.org/api/v3/projects/pycord/versions/$DOCS_VERSION/" \ - --header 'Content-Type: application/json' \ - --header "Authorization: Token ${{ secrets.READTHEDOCS_TOKEN }}" \ - --data '{ - "active": true, - "hidden": $HIDDEN - }' + python3 scripts/manage_rtd_version.py \ + --project pycord \ + --version "${{ needs.pre_config.outputs.version }}" \ + --sync inform_discord: runs-on: ubuntu-latest @@ -270,32 +271,13 @@ jobs: environment: release steps: - name: "Notify Discord" - run: | - VERSION=${{ needs.pre_config.outputs.version }} - MAJOR_MINOR_VERSION=$(echo $VERSION | grep -oE '^[0-9]+\.[0-9]+') - DOCS_URL="" - GITHUB_COMPARE_URL="" - GITHUB_RELEASE_URL="" - PYPI_RELEASE_URL="" - if [[ $VERSION == *rc* ]]; then - ANNOUNCEMENT="## <:pycord:1063211537008955495> Pycord v$VERSION Release Candidate ($MAJOR_MINOR_VERSION) is available!\n\n" - ANNOUNCEMENT="${ANNOUNCEMENT}@here\n\n" - ANNOUNCEMENT="${ANNOUNCEMENT}This is a pre-release (release candidate) for testing and feedback.\n\n" - ANNOUNCEMENT="${ANNOUNCEMENT}You can view the changelog here: <$DOCS_URL>\n\n" - ANNOUNCEMENT="${ANNOUNCEMENT}Check out the [GitHub changelog]($GITHUB_COMPARE_URL), [GitHub release page]($GITHUB_RELEASE_URL), and [PyPI release page]($PYPI_RELEASE_URL).\n\n" - ANNOUNCEMENT="${ANNOUNCEMENT}You can install this version by running the following command:\n\`\`\`sh\npip install -U py-cord==$VERSION\n\`\`\`\n\n" - ANNOUNCEMENT="${ANNOUNCEMENT}Please try it out and let us know your feedback or any issues!" - else - ANNOUNCEMENT="## <:pycord:1063211537008955495> Pycord v${VERSION} is out!\n\n" - ANNOUNCEMENT="${ANNOUNCEMENT}@everyone\n\n" - ANNOUNCEMENT="${ANNOUNCEMENT}You can view the changelog here: <$DOCS_URL>\n\n" - ANNOUNCEMENT="${ANNOUNCEMENT}Feel free to take a look at the [GitHub changelog]($GITHUB_COMPARE_URL), [GitHub release page]($GITHUB_RELEASE_URL) and the [PyPI release page]($PYPI_RELEASE_URL).\n\n" - ANNOUNCEMENT="${ANNOUNCEMENT}You can install this version by running the following command:\n\`\`\`sh\npip install -U py-cord==$VERSION\n\`\`\`" - fi - curl -H "Content-Type: application/json" \ - -X POST \ - -d "{\"content\":\"$ANNOUNCEMENT\",\"allowed_mentions\":{\"parse\":[\"everyone\",\"roles\"]}}" \ - ${{ secrets.DISCORD_WEBHOOK_URL }} + env: + VERSION: ${{ needs.pre_config.outputs.version }} + PREVIOUS_TAG: ${{ needs.pre_config.outputs.previous_tag }} + PREVIOUS_FINAL_TAG: ${{ needs.pre_config.outputs.previous_final_tag }} + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + REPOSITORY: ${{ github.repository }} + run: python scripts/notify_discord.py determine_milestone_id: runs-on: ubuntu-latest diff --git a/scripts/manage_rtd_version.py b/scripts/manage_rtd_version.py new file mode 100644 index 0000000000..b712b5a8b6 --- /dev/null +++ b/scripts/manage_rtd_version.py @@ -0,0 +1,116 @@ +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.request + +API_BASE = "https://readthedocs.org/api/v3" + + +def sync_versions(project: str, token: str) -> None: + url = f"{API_BASE}/projects/{project}/sync-versions/" + req = urllib.request.Request( + url, + data=json.dumps({}).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Authorization": f"Token {token}", + }, + method="POST", + ) + with urllib.request.urlopen(req) as resp: # noqa: S310 + if resp.status >= 300: + raise RuntimeError( + f"Sync versions failed for {project} with status {resp.status}" + ) + + +def activate_version(project: str, docs_version: str, hidden: bool, token: str) -> None: + url = f"{API_BASE}/projects/{project}/versions/{docs_version}/" + payload = {"active": True, "hidden": hidden} + req = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Authorization": f"Token {token}", + }, + method="PATCH", + ) + with urllib.request.urlopen(req) as resp: # noqa: S310 + if resp.status >= 300: + raise RuntimeError( + f"Activating version {docs_version} for {project} failed with status {resp.status}" + ) + + +def determine_docs_version(version: str) -> tuple[str, bool]: + match = re.match( + r"^(?P\d+)\.(?P\d+)\.(?P\d+)(?Prc\d+)?$", version + ) + if not match: + raise ValueError(f"Version '{version}' is not in the expected format") + major = match.group("major") + minor = match.group("minor") + suffix = match.group("suffix") or "" + hidden = bool(suffix) + if hidden: + docs_version = f"v{major}.{minor}.x" + else: + docs_version = f"v{version}" + return docs_version, hidden + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Manage Read the Docs version activation." + ) + parser.add_argument( + "--project", default="pycord", help="RTD project slug (default: pycord)" + ) + parser.add_argument( + "--version", required=True, help="Release version (e.g., 2.6.0 or 2.6.0rc1)" + ) + parser.add_argument("--token", help="RTD token (overrides READTHEDOCS_TOKEN env)") + parser.add_argument( + "--sync", action="store_true", help="Sync versions before activating" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print planned actions without calling RTD", + ) + args = parser.parse_args() + + token = args.token or os.environ.get("READTHEDOCS_TOKEN") + if not token: + sys.exit("Missing Read the Docs token.") + + try: + docs_version, hidden = determine_docs_version(args.version) + except ValueError as exc: + sys.exit(str(exc)) + + if args.dry_run: + plan = { + "project": args.project, + "version": args.version, + "docs_version": docs_version, + "hidden": hidden, + "sync": args.sync, + } + print(json.dumps(plan, indent=2)) + return + + try: + if args.sync: + sync_versions(args.project, token) + activate_version(args.project, docs_version, hidden, token) + except (urllib.error.HTTPError, urllib.error.URLError, RuntimeError) as exc: + sys.exit(str(exc)) + + +if __name__ == "__main__": + main() diff --git a/scripts/notify_discord.py b/scripts/notify_discord.py new file mode 100644 index 0000000000..04182752d8 --- /dev/null +++ b/scripts/notify_discord.py @@ -0,0 +1,97 @@ +import argparse +import json +import os +import sys +import urllib.error +import urllib.request + + +def build_message( + version: str, previous_tag: str, previous_final_tag: str, repo: str +) -> str: + major_minor = version.split(".")[:2] + major_minor_str = ".".join(major_minor) + docs_url = f"https://docs.pycord.dev/en/v{version}/changelog.html" + base_compare = previous_tag + if "rc" not in version: + base_compare = previous_final_tag or previous_tag + compare_url = f"https://github.com/{repo}/compare/{base_compare}...v{version}" + release_url = f"https://github.com/{repo}/releases/tag/v{version}" + pypi_url = f"https://pypi.org/project/py-cord/{version}/" + + if "rc" in version: + heading = f"## <:pycord:1063211537008955495> Pycord v{version} Release Candidate ({major_minor_str}) is available!\n\n" + audience = "@here\n\n" + preface = ( + "This is a pre-release (release candidate) for testing and feedback.\n\n" + ) + docs_line = f"You can view the changelog here: <{docs_url}>\n\n" + links = f"Check out the [GitHub changelog](<{compare_url}>), [GitHub release page](<{release_url}>), and [PyPI release page](<{pypi_url}>).\n\n" + install = f"You can install this version by running the following command:\n```sh\npip install -U py-cord=={version}\n```\n\n" + close = "Please try it out and let us know your feedback or any issues!" + else: + heading = f"## <:pycord:1063211537008955495> Pycord v{version} is out!\n\n" + audience = "@everyone\n\n" + preface = "" + docs_line = f"You can view the changelog here: <{docs_url}>\n\n" + links = f"Feel free to take a look at the [GitHub changelog](<{compare_url}>), [GitHub release page](<{release_url}>) and the [PyPI release page](<{pypi_url}>).\n\n" + install = f"You can install this version by running the following command:\n```sh\npip install -U py-cord=={version}\n```" + close = "" + + return heading + audience + preface + docs_line + links + install + close + + +def send_webhook(webhook_url: str, content: str) -> None: + payload = {"content": content, "allowed_mentions": {"parse": ["everyone", "roles"]}} + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + webhook_url, + data=data, + headers={ + "Content-Type": "application/json", + "User-Agent": "pycord-release-bot/1.0 (+https://github.com/Pycord-Development/pycord)", + "Accept": "*/*", + }, + method="POST", + ) + with urllib.request.urlopen(req) as resp: # noqa: S310 + if resp.status >= 300: + raise RuntimeError(f"Webhook post failed with status {resp.status}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Notify Discord about a release.") + parser.add_argument( + "--dry-run", action="store_true", help="Print payload instead of sending" + ) + parser.add_argument( + "--webhook-url", help="Webhook URL (overrides DISCORD_WEBHOOK_URL)" + ) + args = parser.parse_args() + + version = os.environ.get("VERSION") + previous_tag = os.environ.get("PREVIOUS_TAG") + previous_final_tag = os.environ.get("PREVIOUS_FINAL_TAG") + webhook_url = args.webhook_url or os.environ.get("DISCORD_WEBHOOK_URL") + repo = os.environ.get("REPOSITORY") + + if not all([version, previous_tag, repo]) or (not args.dry_run and not webhook_url): + sys.exit("Missing required environment variables.") + + message = build_message( + version, previous_tag, previous_final_tag or previous_tag, repo + ) + + if args.dry_run: + payload = { + "content": message, + "allowed_mentions": {"parse": ["everyone", "roles"]}, + } + print(json.dumps(payload, indent=2)) + return + + send_webhook(webhook_url, message) + + +if __name__ == "__main__": + main() diff --git a/scripts/trigger_rtd_localizations.py b/scripts/trigger_rtd_localizations.py new file mode 100644 index 0000000000..6d90997f82 --- /dev/null +++ b/scripts/trigger_rtd_localizations.py @@ -0,0 +1,75 @@ +import argparse +import json +import os +import sys +import urllib.error +import urllib.request + + +def trigger_build(project: str, version: str, token: str) -> None: + url = ( + f"https://readthedocs.org/api/v3/projects/{project}/versions/{version}/builds/" + ) + data = json.dumps({}).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Token {token}", + }, + method="POST", + ) + with urllib.request.urlopen(req) as resp: # noqa: S310 + if resp.status >= 300: + raise RuntimeError( + f"Build trigger failed for {project}:{version} with status {resp.status}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Trigger Read the Docs builds for localization projects." + ) + parser.add_argument( + "--project", + action="append", + required=True, + help="Localization project slug. Can be repeated.", + ) + parser.add_argument( + "--version", default="master", help="Version to build (default: master)." + ) + parser.add_argument( + "--token", help="Read the Docs token (overrides READTHEDOCS_TOKEN env)." + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print planned builds instead of sending.", + ) + args = parser.parse_args() + + token = args.token or os.environ.get("READTHEDOCS_TOKEN") + if not token: + sys.exit("Missing Read the Docs token.") + + if args.dry_run: + payload = {"projects": args.project, "version": args.version} + print(json.dumps(payload, indent=2)) + return + + failures = [] + for project in args.project: + try: + trigger_build(project, args.version, token) + except (urllib.error.HTTPError, urllib.error.URLError, RuntimeError) as exc: + failures.append((project, str(exc))) + + if failures: + details = "; ".join([f"{proj}: {err}" for proj, err in failures]) + sys.exit(f"One or more builds failed: {details}") + + +if __name__ == "__main__": + main() diff --git a/scripts/update_changelog.py b/scripts/update_changelog.py new file mode 100644 index 0000000000..6a39141282 --- /dev/null +++ b/scripts/update_changelog.py @@ -0,0 +1,205 @@ +import argparse +import pathlib +import re +import sys +from datetime import date as date_cls + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Update CHANGELOG for a release.") + parser.add_argument("--path", default="CHANGELOG.md", help="Path to CHANGELOG.md") + parser.add_argument("--version", required=True, help="Version being released") + parser.add_argument("--previous-tag", required=True, help="Previous git tag") + parser.add_argument( + "--previous-final-tag", + required=False, + help="Previous final (non-rc) tag; used for final release compare links", + ) + parser.add_argument( + "--branch", required=True, help="Branch name for Unreleased copy" + ) + parser.add_argument("--repository", required=True, help="owner/repo for links") + parser.add_argument( + "--date", default=None, help="Release date (YYYY-MM-DD); defaults to today" + ) + return parser.parse_args() + + +def find_unreleased_section(text: str) -> tuple[int, int]: + match = re.search(r"^## \[Unreleased\]\s*", text, flags=re.M) + if not match: + sys.exit("Missing '## [Unreleased]' heading in changelog.") + start = match.start() + after = match.end() + next_header = re.search(r"^## \[", text[after:], flags=re.M) + end = after + next_header.start() if next_header else len(text) + return start, end + + +def build_unreleased_block(branch: str) -> str: + lines = [ + "## [Unreleased]", + "", + f"These changes are available on the `{branch}` branch, but have not yet been released.", + "", + "### Added", + "", + "### Changed", + "", + "### Fixed", + "", + "### Deprecated", + "", + "### Removed", + "", + ] + return "\n".join(lines) + + +CATEGORY_ORDER = ["Added", "Changed", "Fixed", "Deprecated", "Removed"] + + +def parse_categories(section_body: str) -> dict[str, list[str]]: + """Parse a section body into category -> list of lines (without the heading).""" + categories: dict[str, list[str]] = {name: [] for name in CATEGORY_ORDER} + current: str | None = None + + for line in section_body.splitlines(): + heading_match = re.match(r"^###\s+(.+)$", line) + if heading_match: + title = heading_match.group(1).strip() + current = title if title in categories else None + continue + if current: + categories[current].append(line) + return categories + + +def merge_categories(dest: dict[str, list[str]], src: dict[str, list[str]]) -> None: + for key in CATEGORY_ORDER: + if src.get(key): + dest[key].extend(src[key]) + + +def _normalize_lines(lines: list[str]) -> list[str]: + """Keep only non-empty lines to avoid gaps inside category lists.""" + return [line for line in lines if line.strip()] + + +def render_release_body(categories: dict[str, list[str]]) -> str: + parts: list[str] = [] + for name in CATEGORY_ORDER: + body = _normalize_lines(categories[name]) + if not any(line.strip() for line in body): + continue + parts.append(f"### {name}") + if body: + parts.append("") + parts.extend(body) + parts.append("") + return "\n".join(parts).rstrip("\n") + + +def update_links( + text: str, + version: str, + previous_tag: str, + repository: str, + previous_final_tag: str | None, +) -> str: + unreleased_link = f"[unreleased]: https://github.com/{repository}/compare/v{version}...HEAD" + + base_tag = previous_tag + if "rc" not in version: + base_tag = previous_final_tag or previous_tag + + release_link = f"[{version}]: https://github.com/{repository}/compare/{base_tag}...v{version}" + + updated = re.sub(r"^\[unreleased\]: .*", unreleased_link, text, flags=re.M) + + if re.search(rf"^\[{re.escape(version)}\]: ", updated, flags=re.M): + updated = re.sub( + rf"^\[{re.escape(version)}\]: .*", release_link, updated, flags=re.M + ) + else: + + def insert_after_unreleased(match: re.Match) -> str: + return match.group(0) + "\n" + release_link + + new_updated = re.sub( + r"^\[unreleased\]: .*$", + insert_after_unreleased, + updated, + flags=re.M, + count=1, + ) + if new_updated == updated: + new_updated = updated.rstrip("\n") + "\n" + release_link + "\n" + updated = new_updated + + return updated + + +def main() -> None: + args = parse_args() + changelog_path = pathlib.Path(args.path) + if not changelog_path.exists(): + sys.exit(f"Changelog not found at {changelog_path}") + + release_date = args.date or date_cls.today().isoformat() + + text = changelog_path.read_text() + start, end = find_unreleased_section(text) + unreleased_body = text[text.find("\n", start, end) + 1 : end].rstrip("\n") + + rest = text[end:] + + rc_bodies: list[str] = [] + if "rc" not in args.version: + section_pattern = re.compile(r"^## \[(?P[^\]]+)\][^\n]*\n", re.M) + matches = list(section_pattern.finditer(rest)) + base_prefix = f"{args.version}rc" + + collecting = False + for idx, match in enumerate(matches): + title = match.group("title") + is_rc = title.startswith(base_prefix) + + if is_rc and not collecting: + collecting = True + if collecting and not is_rc: + break + if not collecting: + continue + + body_start = match.end() + body_end = matches[idx + 1].start() if idx + 1 < len(matches) else len(rest) + rc_bodies.append(rest[body_start:body_end].rstrip("\n")) + + new_unreleased = build_unreleased_block(args.branch) + + aggregated = parse_categories(unreleased_body) + for body in rc_bodies: + merge_categories(aggregated, parse_categories(body)) + + release_body = render_release_body(aggregated) + + release_section = f"## [{args.version}] - {release_date}\n{release_body}\n" + + updated = text[:start] + new_unreleased + "\n" + release_section + rest + updated = update_links( + updated, + args.version, + args.previous_tag, + args.repository, + args.previous_final_tag, + ) + + if not updated.endswith("\n"): + updated += "\n" + + changelog_path.write_text(updated) + + +if __name__ == "__main__": + main() From d16f857a76954be64b9b10674b3ef99a068c29ca Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 15:43:51 +0000 Subject: [PATCH 020/117] style(pre-commit): auto fixes from pre-commit.com hooks --- scripts/update_changelog.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/update_changelog.py b/scripts/update_changelog.py index fc6d9d4ad6..b98415c642 100644 --- a/scripts/update_changelog.py +++ b/scripts/update_changelog.py @@ -131,13 +131,17 @@ def update_links( repository: str, previous_final_tag: str | None, ) -> str: - unreleased_link = f"[unreleased]: https://github.com/{repository}/compare/v{version}...HEAD" + unreleased_link = ( + f"[unreleased]: https://github.com/{repository}/compare/v{version}...HEAD" + ) base_tag = previous_tag if "rc" not in version: base_tag = previous_final_tag or previous_tag - release_link = f"[{version}]: https://github.com/{repository}/compare/{base_tag}...v{version}" + release_link = ( + f"[{version}]: https://github.com/{repository}/compare/{base_tag}...v{version}" + ) updated = re.sub(r"^\[unreleased\]: .*", unreleased_link, text, flags=re.M) From 91390cbaeb39ce32ab3c341c04e4b085fae66d33 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 15:58:03 +0000 Subject: [PATCH 021/117] style(pre-commit): auto fixes from pre-commit.com hooks --- scripts/manage_rtd_version.py | 4 ++-- scripts/notify_discord.py | 2 +- scripts/trigger_rtd_localizations.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/manage_rtd_version.py b/scripts/manage_rtd_version.py index 6f1b060374..96224f6506 100644 --- a/scripts/manage_rtd_version.py +++ b/scripts/manage_rtd_version.py @@ -44,7 +44,7 @@ def sync_versions(project: str, token: str) -> None: }, method="POST", ) - with urllib.request.urlopen(req) as resp: # nosec - not applicable + with urllib.request.urlopen(req) as resp: # nosec - not applicable if resp.status >= 300: raise RuntimeError( f"Sync versions failed for {project} with status {resp.status}" @@ -63,7 +63,7 @@ def activate_version(project: str, docs_version: str, hidden: bool, token: str) }, method="PATCH", ) - with urllib.request.urlopen(req) as resp: # nosec - not applicable + with urllib.request.urlopen(req) as resp: # nosec - not applicable if resp.status >= 300: raise RuntimeError( f"Activating version {docs_version} for {project} failed with status {resp.status}" diff --git a/scripts/notify_discord.py b/scripts/notify_discord.py index ba23ae99a4..58c17aec92 100644 --- a/scripts/notify_discord.py +++ b/scripts/notify_discord.py @@ -78,7 +78,7 @@ def send_webhook(webhook_url: str, content: str) -> None: }, method="POST", ) - with urllib.request.urlopen(req) as resp: # nosec - not applicable + with urllib.request.urlopen(req) as resp: # nosec - not applicable if resp.status >= 300: raise RuntimeError(f"Webhook post failed with status {resp.status}") diff --git a/scripts/trigger_rtd_localizations.py b/scripts/trigger_rtd_localizations.py index 8d3d5f2929..2302f36c5d 100644 --- a/scripts/trigger_rtd_localizations.py +++ b/scripts/trigger_rtd_localizations.py @@ -44,7 +44,7 @@ def trigger_build(project: str, version: str, token: str) -> None: }, method="POST", ) - with urllib.request.urlopen(req) as resp: # nosec - not applicable + with urllib.request.urlopen(req) as resp: # nosec - not applicable if resp.status >= 300: raise RuntimeError( f"Build trigger failed for {project}:{version} with status {resp.status}" From 2f48c7bfd8aec30add26f82a5b2c21f8d9f50ce8 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:23:33 -0500 Subject: [PATCH 022/117] revert cl --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 594c9c73fe..39840869c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,6 @@ These changes are available on the `master` branch, but have not yet been releas ([#2956](https://github.com/Pycord-Development/pycord/pull/2956)) - Added `Guild.fetch_roles_member_counts` method and `GuildRoleCounts` class. ([#3020](https://github.com/Pycord-Development/pycord/pull/3020)) -- Added `replace_item` to `DesignerView`, `Section`, `Container`, `ActionRow`, & - `MediaGallery` ([#3032](https://github.com/Pycord-Development/pycord/pull/3032)) ### Changed @@ -35,8 +33,6 @@ These changes are available on the `master` branch, but have not yet been releas TypeVars. ([#3002](https://github.com/Pycord-Development/pycord/pull/3002)) - Fixed `View`'s `disable_on_timeout` not working in private (DM) channels. ([#3016](https://github.com/Pycord-Development/pycord/pull/3016)) -- Fixed core issues with modifying items in `Container` and `Section` - ([#3032](https://github.com/Pycord-Development/pycord/pull/3032)) ### Deprecated From ea33a62d5b6ad77b404f0790de09d4e31b8031db Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:26:16 -0500 Subject: [PATCH 023/117] files --- ...date_changelog.py => release_changelog.py} | 0 scripts/trigger_rtd_localizations.py | 99 ------------------- 2 files changed, 99 deletions(-) rename scripts/{update_changelog.py => release_changelog.py} (100%) delete mode 100644 scripts/trigger_rtd_localizations.py diff --git a/scripts/update_changelog.py b/scripts/release_changelog.py similarity index 100% rename from scripts/update_changelog.py rename to scripts/release_changelog.py diff --git a/scripts/trigger_rtd_localizations.py b/scripts/trigger_rtd_localizations.py deleted file mode 100644 index 2302f36c5d..0000000000 --- a/scripts/trigger_rtd_localizations.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2025 Lala Sabathil <lala@pycord.dev> & Pycord Development - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -import argparse -import json -import os -import sys -import urllib.error -import urllib.request - - -def trigger_build(project: str, version: str, token: str) -> None: - url = ( - f"https://readthedocs.org/api/v3/projects/{project}/versions/{version}/builds/" - ) - data = json.dumps({}).encode("utf-8") - req = urllib.request.Request( - url, - data=data, - headers={ - "Content-Type": "application/json", - "Authorization": f"Token {token}", - }, - method="POST", - ) - with urllib.request.urlopen(req) as resp: # nosec - not applicable - if resp.status >= 300: - raise RuntimeError( - f"Build trigger failed for {project}:{version} with status {resp.status}" - ) - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Trigger Read the Docs builds for localization projects." - ) - parser.add_argument( - "--project", - action="append", - required=True, - help="Localization project slug. Can be repeated.", - ) - parser.add_argument( - "--version", default="master", help="Version to build (default: master)." - ) - parser.add_argument( - "--token", help="Read the Docs token (overrides READTHEDOCS_TOKEN env)." - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Print planned builds instead of sending.", - ) - args = parser.parse_args() - - token = args.token or os.environ.get("READTHEDOCS_TOKEN") - if not token: - sys.exit("Missing Read the Docs token.") - - if args.dry_run: - payload = {"projects": args.project, "version": args.version} - print(json.dumps(payload, indent=2)) - return - - failures = [] - for project in args.project: - try: - trigger_build(project, args.version, token) - except (urllib.error.HTTPError, urllib.error.URLError, RuntimeError) as exc: - failures.append((project, str(exc))) - - if failures: - details = "; ".join([f"{proj}: {err}" for proj, err in failures]) - sys.exit(f"One or more builds failed: {details}") - - -if __name__ == "__main__": - main() From 2ba67c3d25b7053ce14669a603721158be87c7d3 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:27:26 -0500 Subject: [PATCH 024/117] file again --- scripts/{manage_rtd_version.py => release_rtd_version.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename scripts/{manage_rtd_version.py => release_rtd_version.py} (100%) diff --git a/scripts/manage_rtd_version.py b/scripts/release_rtd_version.py similarity index 100% rename from scripts/manage_rtd_version.py rename to scripts/release_rtd_version.py From 3c49f090f6b85efff0b047b5b1be72504817e7f8 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:28:10 -0500 Subject: [PATCH 025/117] one more --- scripts/{notify_discord.py => discord_release_notification.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename scripts/{notify_discord.py => discord_release_notification.py} (100%) diff --git a/scripts/notify_discord.py b/scripts/discord_release_notification.py similarity index 100% rename from scripts/notify_discord.py rename to scripts/discord_release_notification.py From 1ae68351f28a7653041367b420f4e0fc23bf36b6 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Wed, 24 Dec 2025 17:29:21 -0500 Subject: [PATCH 026/117] cl --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52bac2e2af..a640ce8f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,14 @@ possible (see our [Version Guarantees] for more info). These changes are available on the `master` branch, but have not yet been released. ### Added +- Added `replace_item` to `DesignerView`, `Section`, `Container`, `ActionRow`, & + `MediaGallery` ([#3032](https://github.com/Pycord-Development/pycord/pull/3032)) ### Changed ### Fixed +- Fixed core issues with modifying items in `Container` and `Section` + ([#3032](https://github.com/Pycord-Development/pycord/pull/3032)) ### Deprecated From eb9fa928de187975ecebf78ed654cdc858d31742 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 22:29:49 +0000 Subject: [PATCH 027/117] style(pre-commit): auto fixes from pre-commit.com hooks --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a640ce8f60..defad28296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,14 @@ possible (see our [Version Guarantees] for more info). These changes are available on the `master` branch, but have not yet been released. ### Added + - Added `replace_item` to `DesignerView`, `Section`, `Container`, `ActionRow`, & `MediaGallery` ([#3032](https://github.com/Pycord-Development/pycord/pull/3032)) ### Changed ### Fixed + - Fixed core issues with modifying items in `Container` and `Section` ([#3032](https://github.com/Pycord-Development/pycord/pull/3032)) From 2488e63705ba2687d0fdf25afa8d50ee8e50db5b Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Thu, 25 Dec 2025 05:09:00 -0500 Subject: [PATCH 028/117] buildout for new features & items aliases --- discord/ui/action_row.py | 8 ++++++ discord/ui/core.py | 55 +++++++++++++++++++++++++++++++++------- discord/ui/modal.py | 7 +++++ discord/ui/view.py | 41 ++++++++++++++++++++++++++++-- 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 2fb53bba8a..c428fbbfcd 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -107,6 +107,14 @@ def __init__( for i in items: self.add_item(i) + @property + def items(self) -> list[ViewItem]: + return self.children + + @items.setter + def items(self, value: list[ViewItem]) -> None: + self.children = value + def _add_component_from_item(self, item: ViewItem): self.underlying.children.append(item._generate_underlying()) diff --git a/discord/ui/core.py b/discord/ui/core.py index 21bb16871a..e80fcd731e 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -115,30 +115,67 @@ def _dispatch_timeout(self): def to_components(self) -> list[dict[str, Any]]: return [item.to_component_dict() for item in self.children] - def get_item(self, custom_id: str | int) -> Item | None: - """Gets an item from this structure. Roughly equal to `utils.get(self.children, ...)`. + def get_item(self, custom_id: str | int | None = None, **attrs: Any) -> Item | None: + """Gets an item from this structure. Roughly equal to `utils.get(self.children, **attrs)`. If an :class:`int` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. This method will also search nested items. + If ``attrs`` are provided, it will check them by logical AND as done in :func:`~utils.get`. + To have a nested attribute search (i.e. search by ``x.y``) then pass in ``x__y`` as the keyword argument. + + Examples + --------- + + Basic usage: + + .. code-block:: python3 + + container = my_view.get(1234) + + Attribute matching: + + .. code-block:: python3 + + button = my_view.get(label='Click me!', style=discord.ButtonStyle.danger) Parameters ---------- - custom_id: Union[:class:`str`, :class:`int`] + custom_id: Optional[Union[:class:`str`, :class:`int`]] The id of the item to get + \*\*attrs + Keyword arguments that denote attributes to search with. Returns ------- Optional[:class:`Item`] - The item with the matching ``custom_id`` or ``id`` if it exists. + The item with the matching ``custom_id``, ``id``, or ``attrs`` if it exists. """ - if not custom_id: + if not (custom_id or attrs): return None - attr = "id" if isinstance(custom_id, int) else "custom_id" - child = find(lambda i: getattr(i, attr, None) == custom_id, self.children) - if not child: + child = None + if custom_id: + attr = "id" if isinstance(custom_id, int) else "custom_id" + child = find(lambda i: getattr(i, attr, None) == custom_id, self.children) + if not child: + for i in self.children: + if hasattr(i, "get_item"): + if child := i.get_item(custom_id): + return child + elif attrs: + _all = all + attrget = attrgetter for i in self.children: + converted = [ + (attrget(attr.replace("__", ".")), value) for attr, value in attrs.items() + ] + try: + if _all(pred(elem) == value for pred, value in converted): + return elem + except: + pass if hasattr(i, "get_item"): - if child := i.get_item(custom_id): + if child := i.get_item(custom_id, **attrs): return child + return child def add_item(self, item: Item) -> Self: diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 8620cec363..971648d7b6 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -251,6 +251,13 @@ async def on_timeout(self) -> None: A callback that is called when a modal's timeout elapses without being explicitly stopped. """ + def walk_children(self) -> Iterator[ModalItem]: + for item in self.children: + if hasattr(item, "walk_items"): + yield from item.walk_items() + else: + yield item + class Modal(BaseModal): """Represents a legacy UI modal for InputText components. diff --git a/discord/ui/view.py b/discord/ui/view.py index edd8b9ef81..92429390d2 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -823,6 +823,14 @@ def __init__( *items, timeout=timeout, disable_on_timeout=disable_on_timeout, store=store ) + @property + def items(self) -> list[ViewItem[V]]: + return self.children + + @items.setter + def items(self, value: list[ViewItem[V]]) -> None: + self.children = value + @classmethod def from_message( cls, message: Message, /, *, timeout: float | None = 180.0 @@ -881,13 +889,36 @@ def from_dict( view.add_item(_component_to_item(component)) return view - def add_item(self, item: ViewItem[V]) -> Self: + def add_item(self, + item: ViewItem[V], + *, + index: int | None = None, + before: ViewItem[V] | str | int | None = None, + after: ViewItem[V] | str | int | None = None, + into: ViewItem[V] | str | int | None = None, + ) -> Self: """Adds an item to the view. + .. warning:: + + You may specify only **one** of ``index``, ``before``, & ``after``. ``into`` will work together with those parameters. + + .. versionchanged:: 2.7.1 + Added new parameters ``index``, ``before``, ``after``, & ``into``. + Parameters ---------- item: :class:`ViewItem` The item to add to the view. + index: Optional[class:`int`] + Add the new item at the specific index of :attr:`children`. Same behavior as Python's :func:`~list.insert`. + before: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **before** the specified item. If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. + after: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **after** the specified item. If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. + into: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **into** the specified item. This would be equivalent to `into.add_item(item)`, where `into` is a :class:`ViewItem`. + If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. Raises ------ @@ -896,6 +927,12 @@ def add_item(self, item: ViewItem[V]) -> Self: ValueError Maximum number of items has been exceeded (40) """ + if ( + before and after + or before and (index is not None) + or after and (index is not None) + ): + raise ValueError("Can only specify one of before, after, and index.") if isinstance(item.underlying, (SelectComponent, ButtonComponent)): raise ValueError( @@ -909,7 +946,7 @@ def replace_item( self, original_item: ViewItem[V] | str | int, new_item: ViewItem[V] ) -> Self: """Directly replace an item in this view. - If an :class:`int` is provided, the item will be replaced by ``id``, otherwise by ``custom_id``. + If an :class:`int` is provided, the item will be replaced by ``id``, otherwise by ``custom_id``. Parameters ---------- From 5ebdeaa741f991c69eea4ccd784aeacec4d5e01a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 25 Dec 2025 10:09:30 +0000 Subject: [PATCH 029/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/core.py | 5 +++-- discord/ui/view.py | 12 ++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/discord/ui/core.py b/discord/ui/core.py index e80fcd731e..745de2bd9a 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -116,7 +116,7 @@ def to_components(self) -> list[dict[str, Any]]: return [item.to_component_dict() for item in self.children] def get_item(self, custom_id: str | int | None = None, **attrs: Any) -> Item | None: - """Gets an item from this structure. Roughly equal to `utils.get(self.children, **attrs)`. + r"""Gets an item from this structure. Roughly equal to `utils.get(self.children, **attrs)`. If an :class:`int` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. This method will also search nested items. If ``attrs`` are provided, it will check them by logical AND as done in :func:`~utils.get`. @@ -165,7 +165,8 @@ def get_item(self, custom_id: str | int | None = None, **attrs: Any) -> Item | N attrget = attrgetter for i in self.children: converted = [ - (attrget(attr.replace("__", ".")), value) for attr, value in attrs.items() + (attrget(attr.replace("__", ".")), value) + for attr, value in attrs.items() ] try: if _all(pred(elem) == value for pred, value in converted): diff --git a/discord/ui/view.py b/discord/ui/view.py index 92429390d2..4d03efe20c 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -889,7 +889,8 @@ def from_dict( view.add_item(_component_to_item(component)) return view - def add_item(self, + def add_item( + self, item: ViewItem[V], *, index: int | None = None, @@ -928,9 +929,12 @@ def add_item(self, Maximum number of items has been exceeded (40) """ if ( - before and after - or before and (index is not None) - or after and (index is not None) + before + and after + or before + and (index is not None) + or after + and (index is not None) ): raise ValueError("Can only specify one of before, after, and index.") From 050f639e4d62e35849f12dfe1b7231b694dfc78c Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Thu, 25 Dec 2025 05:11:35 -0500 Subject: [PATCH 030/117] fix --- discord/ui/core.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/discord/ui/core.py b/discord/ui/core.py index 745de2bd9a..d9579b1f19 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -28,6 +28,7 @@ import time from itertools import groupby from typing import TYPE_CHECKING, Any, Callable +from operator import attrgetter from ..utils import find, get from .item import Item, ItemCallbackType @@ -169,8 +170,8 @@ def get_item(self, custom_id: str | int | None = None, **attrs: Any) -> Item | N for attr, value in attrs.items() ] try: - if _all(pred(elem) == value for pred, value in converted): - return elem + if _all(pred(i) == value for pred, value in converted): + return i except: pass if hasattr(i, "get_item"): From d8313188b3ffc52bfef85ea42e85051cdd1717e7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 25 Dec 2025 10:12:03 +0000 Subject: [PATCH 031/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/core.py b/discord/ui/core.py index d9579b1f19..2c00309710 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -27,8 +27,8 @@ import asyncio import time from itertools import groupby -from typing import TYPE_CHECKING, Any, Callable from operator import attrgetter +from typing import TYPE_CHECKING, Any, Callable from ..utils import find, get from .item import Item, ItemCallbackType From 6eb6086df779c5398c560352dda4b85b39aff5b6 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Thu, 25 Dec 2025 05:20:21 -0500 Subject: [PATCH 032/117] Iterator, --- discord/ui/modal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 971648d7b6..3800bcc6b7 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -30,7 +30,7 @@ import time from functools import partial from itertools import groupby -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar, Iterator from ..enums import ComponentType from ..utils import find From 6496c4cc6b6600de6749ed80ffbdaec9152a8e20 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 25 Dec 2025 10:20:49 +0000 Subject: [PATCH 033/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/modal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 3800bcc6b7..c9f86b9434 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -30,7 +30,7 @@ import time from functools import partial from itertools import groupby -from typing import TYPE_CHECKING, Any, TypeVar, Iterator +from typing import TYPE_CHECKING, Any, Iterator, TypeVar from ..enums import ComponentType from ..utils import find From de61d8e8487e695162773ef40f204adb6d21baa8 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 29 Dec 2025 07:42:03 -0500 Subject: [PATCH 034/117] fix modal typing --- discord/client.py | 2 +- discord/interactions.py | 10 +++++----- discord/state.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/discord/client.py b/discord/client.py index f339cbfe92..c686499263 100644 --- a/discord/client.py +++ b/discord/client.py @@ -594,7 +594,7 @@ async def on_modal_error(self, error: Exception, interaction: Interaction) -> No The default modal error handler provided by the client. The default implementation prints the traceback to stderr. - This only fires for a modal if you did not define its :func:`~discord.ui.Modal.on_error`. + This only fires for a modal if you did not define its :func:`~discord.ui.BaseModal.on_error`. Parameters ---------- diff --git a/discord/interactions.py b/discord/interactions.py index d79d3e1cd2..fd09b01064 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -88,7 +88,7 @@ from .types.interactions import InteractionCallbackResponse, InteractionData from .types.interactions import InteractionMetadata as InteractionMetadataPayload from .types.interactions import MessageInteraction as MessageInteractionPayload - from .ui.modal import Modal + from .ui.modal import BaseModal from .ui.view import BaseView InteractionChannel = Union[ @@ -168,7 +168,7 @@ class Interaction: The view that this interaction belongs to. .. versionadded:: 2.7 - modal: Optional[:class:`Modal`] + modal: Optional[:class:`BaseModal`] The modal that this interaction belongs to. .. versionadded:: 2.7 @@ -258,7 +258,7 @@ def _from_data(self, data: InteractionPayload): self.command: ApplicationCommand | None = None self.view: BaseView | None = None - self.modal: Modal | None = None + self.modal: BaseModal | None = None self.attachment_size_limit: int = data.get("attachment_size_limit") self.message: Message | None = None @@ -1334,14 +1334,14 @@ async def send_autocomplete_result( self._responded = True await self._process_callback_response(callback_response) - async def send_modal(self, modal: Modal) -> Interaction: + async def send_modal(self, modal: BaseModal) -> Interaction: """|coro| Responds to this interaction by sending a modal dialog. This cannot be used to respond to another modal dialog submission. Parameters ---------- - modal: :class:`discord.ui.Modal` + modal: :class:`discord.ui.BaseModal` The modal dialog to display to the user. Raises diff --git a/discord/state.py b/discord/state.py index 8222f5fbe5..943b307e34 100644 --- a/discord/state.py +++ b/discord/state.py @@ -70,7 +70,7 @@ from .stage_instance import StageInstance from .sticker import GuildSticker from .threads import Thread, ThreadMember -from .ui.modal import Modal, ModalStore +from .ui.modal import BaseModal, ModalStore from .ui.view import BaseView, ViewStore from .user import ClientUser, User @@ -413,7 +413,7 @@ def store_view(self, view: BaseView, message_id: int | None = None) -> None: def purge_message_view(self, message_id: int) -> None: self._view_store.remove_message_view(message_id) - def store_modal(self, modal: Modal, message_id: int) -> None: + def store_modal(self, modal: BaseModal, message_id: int) -> None: self._modal_store.add_modal(modal, message_id) def prevent_view_updates_for(self, message_id: int) -> BaseView | None: From 66a4db6a4c2aac76f3acb365b1a627e27dafd2c5 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:44:55 -0500 Subject: [PATCH 035/117] correct return types --- discord/client.py | 2 +- discord/ui/view.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/discord/client.py b/discord/client.py index c686499263..f339cbfe92 100644 --- a/discord/client.py +++ b/discord/client.py @@ -594,7 +594,7 @@ async def on_modal_error(self, error: Exception, interaction: Interaction) -> No The default modal error handler provided by the client. The default implementation prints the traceback to stderr. - This only fires for a modal if you did not define its :func:`~discord.ui.BaseModal.on_error`. + This only fires for a modal if you did not define its :func:`~discord.ui.Modal.on_error`. Parameters ---------- diff --git a/discord/ui/view.py b/discord/ui/view.py index 4d03efe20c..98560fc130 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -713,7 +713,7 @@ def add_item(self, item: ViewItem[V]) -> Self: self.__weights.add_item(item) return self - def remove_item(self, item: ViewItem[V] | int | str) -> None: + def remove_item(self, item: ViewItem[V] | int | str) -> Self: """Removes an item from the view. If an :class:`int` or :class:`str` is passed, the item will be removed by Item ``id`` or ``custom_id`` respectively. @@ -730,7 +730,7 @@ def remove_item(self, item: ViewItem[V] | int | str) -> None: pass return self - def clear_items(self) -> None: + def clear_items(self) -> Self: """Removes all items from the view.""" super().clear_items() self.__weights.clear() From de42bb67632b3cf44052771fdcf923f80e07ba17 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:52:18 -0500 Subject: [PATCH 036/117] fix modal error docs --- discord/client.py | 2 +- discord/ui/modal.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/discord/client.py b/discord/client.py index f339cbfe92..c686499263 100644 --- a/discord/client.py +++ b/discord/client.py @@ -594,7 +594,7 @@ async def on_modal_error(self, error: Exception, interaction: Interaction) -> No The default modal error handler provided by the client. The default implementation prints the traceback to stderr. - This only fires for a modal if you did not define its :func:`~discord.ui.Modal.on_error`. + This only fires for a modal if you did not define its :func:`~discord.ui.BaseModal.on_error`. Parameters ---------- diff --git a/discord/ui/modal.py b/discord/ui/modal.py index c9f86b9434..1051156f80 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -238,8 +238,6 @@ async def on_error(self, error: Exception, interaction: Interaction) -> None: ---------- error: :class:`Exception` The exception that was raised. - modal: :class:`BaseModal` - The modal that failed the dispatch. interaction: :class:`~discord.Interaction` The interaction that led to the failure. """ From c4000cbc25a14659112e731f20a5084f89db44d2 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 29 Dec 2025 10:01:17 -0500 Subject: [PATCH 037/117] remove incorrect release script --- scripts/release_rtd_version.py | 140 --------------------------------- 1 file changed, 140 deletions(-) delete mode 100644 scripts/release_rtd_version.py diff --git a/scripts/release_rtd_version.py b/scripts/release_rtd_version.py deleted file mode 100644 index 96224f6506..0000000000 --- a/scripts/release_rtd_version.py +++ /dev/null @@ -1,140 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2025 Lala Sabathil <lala@pycord.dev> & Pycord Development - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -import argparse -import json -import os -import re -import sys -import urllib.error -import urllib.request - -API_BASE = "https://readthedocs.org/api/v3" - - -def sync_versions(project: str, token: str) -> None: - url = f"{API_BASE}/projects/{project}/sync-versions/" - req = urllib.request.Request( - url, - data=json.dumps({}).encode("utf-8"), - headers={ - "Content-Type": "application/json", - "Authorization": f"Token {token}", - }, - method="POST", - ) - with urllib.request.urlopen(req) as resp: # nosec - not applicable - if resp.status >= 300: - raise RuntimeError( - f"Sync versions failed for {project} with status {resp.status}" - ) - - -def activate_version(project: str, docs_version: str, hidden: bool, token: str) -> None: - url = f"{API_BASE}/projects/{project}/versions/{docs_version}/" - payload = {"active": True, "hidden": hidden} - req = urllib.request.Request( - url, - data=json.dumps(payload).encode("utf-8"), - headers={ - "Content-Type": "application/json", - "Authorization": f"Token {token}", - }, - method="PATCH", - ) - with urllib.request.urlopen(req) as resp: # nosec - not applicable - if resp.status >= 300: - raise RuntimeError( - f"Activating version {docs_version} for {project} failed with status {resp.status}" - ) - - -def determine_docs_version(version: str) -> tuple[str, bool]: - match = re.match( - r"^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?P<suffix>rc\d+)?$", version - ) - if not match: - raise ValueError(f"Version '{version}' is not in the expected format") - major = match.group("major") - minor = match.group("minor") - suffix = match.group("suffix") or "" - hidden = bool(suffix) - if hidden: - docs_version = f"v{major}.{minor}.x" - else: - docs_version = f"v{version}" - return docs_version, hidden - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Manage Read the Docs version activation." - ) - parser.add_argument( - "--project", default="pycord", help="RTD project slug (default: pycord)" - ) - parser.add_argument( - "--version", required=True, help="Release version (e.g., 2.6.0 or 2.6.0rc1)" - ) - parser.add_argument("--token", help="RTD token (overrides READTHEDOCS_TOKEN env)") - parser.add_argument( - "--sync", action="store_true", help="Sync versions before activating" - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Print planned actions without calling RTD", - ) - args = parser.parse_args() - - token = args.token or os.environ.get("READTHEDOCS_TOKEN") - if not token: - sys.exit("Missing Read the Docs token.") - - try: - docs_version, hidden = determine_docs_version(args.version) - except ValueError as exc: - sys.exit(str(exc)) - - if args.dry_run: - plan = { - "project": args.project, - "version": args.version, - "docs_version": docs_version, - "hidden": hidden, - "sync": args.sync, - } - print(json.dumps(plan, indent=2)) - return - - try: - if args.sync: - sync_versions(args.project, token) - activate_version(args.project, docs_version, hidden, token) - except (urllib.error.HTTPError, urllib.error.URLError, RuntimeError) as exc: - sys.exit(str(exc)) - - -if __name__ == "__main__": - main() From c7a398339e4c6b9eb05f7a489927172ffca621cb Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Sun, 4 Jan 2026 14:23:04 -0500 Subject: [PATCH 038/117] fix paginator --- discord/ext/pages/pagination.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/discord/ext/pages/pagination.py b/discord/ext/pages/pagination.py index fbc7ca512d..f2d48c12e3 100644 --- a/discord/ext/pages/pagination.py +++ b/discord/ext/pages/pagination.py @@ -25,6 +25,7 @@ from __future__ import annotations from typing import List +from typing_extensions import Self import discord from discord.errors import DiscordException @@ -909,6 +910,12 @@ def update_custom_view(self, custom_view: discord.ui.View): for item in custom_view.children: self.add_item(item) + def clear_items(self) -> Self: + # Necessary override due to behavior of Item.parent, see #3057 + self.children.clear() + self._View__weights.clear() + return self + def get_page_group_content(self, page_group: PageGroup) -> list[Page]: """Returns a converted list of `Page` objects for the given page group based on the content of its pages.""" return [self.get_page_content(page) for page in page_group.pages] From 5e02950b492528f3d603a20b8b82bbfb0d01ba7c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 19:23:32 +0000 Subject: [PATCH 039/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ext/pages/pagination.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/ext/pages/pagination.py b/discord/ext/pages/pagination.py index f2d48c12e3..3aeb094852 100644 --- a/discord/ext/pages/pagination.py +++ b/discord/ext/pages/pagination.py @@ -25,6 +25,7 @@ from __future__ import annotations from typing import List + from typing_extensions import Self import discord From 572fe5a0176f4304c99340afd4a2d9f720dc38d6 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:35:19 -0500 Subject: [PATCH 040/117] doc fix --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index c428fbbfcd..632be4faf8 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -341,7 +341,7 @@ def add_select( id: int | None = None, default_values: Sequence[SelectDefaultValue] | None = None, ) -> Self: - """Adds a :class:`Select` to the container. + """Adds a :class:`Select` to the action row. To append a pre-existing :class:`Select`, use the :meth:`add_item` method instead. From d9b9c1fbd83670340219b9cf5d386b3f784f6d17 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:52:22 -0500 Subject: [PATCH 041/117] add convenience methods to DesignerView --- discord/ui/container.py | 4 +- discord/ui/view.py | 156 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 157 insertions(+), 3 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index de35f67d12..7a2e3c8563 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -27,7 +27,7 @@ from typing import TYPE_CHECKING, Iterator, TypeVar from ..colour import Colour -from ..components import ActionRow +from ..components import MediaGalleryItem from ..components import Container as ContainerComponent from ..components import _component_factory from ..enums import ComponentType, SeparatorSpacingSize @@ -312,7 +312,7 @@ def add_text(self, content: str, id: int | None = None) -> Self: def add_gallery( self, - *items: ViewItem, + *items: MediaGalleryItem, id: int | None = None, ) -> Self: """Adds a :class:`MediaGallery` to the container. diff --git a/discord/ui/view.py b/discord/ui/view.py index 98560fc130..b24b598ad6 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -57,7 +57,7 @@ from ..components import TextDisplay as TextDisplayComponent from ..components import Thumbnail as ThumbnailComponent from ..components import _component_factory -from ..enums import ChannelType +from ..enums import ChannelType, SeparatorSpacingSize from ..utils import find from .core import ItemInterface from .item import ItemCallbackType, ViewItem @@ -72,6 +72,7 @@ if TYPE_CHECKING: + from ..components import MediaGalleryItem from ..interactions import Interaction, InteractionMessage from ..message import Message from ..state import ConnectionState @@ -984,6 +985,159 @@ def replace_item( raise ValueError(f"Could not find original_item in view.") return self + def add_row( + self, + *items: ViewItem[V], + id: int | None = None, + ) -> Self: + """Adds an :class:`ActionRow` to the view. + + To append a pre-existing :class:`ActionRow`, use :meth:`add_item` instead. + + Parameters + ---------- + *items: Union[:class:`Button`, :class:`Select`] + The items this action row contains. + id: Optiona[:class:`int`] + The action row's ID. + """ + + a = ActionRow(*items, id=id) + + return self.add_item(a) + + def add_container( + self, + *items: ViewItem[V], + id: int | None = None, + ) -> Self: + """Adds a :class:`Container` to the view. + + To append a pre-existing :class:`Container`, use the + :meth:`add_item` method, instead. + + Parameters + ---------- + *items: :class:`ViewItem` + The items contained in this container. + accessory: Optional[:class:`ViewItem`] + id: Optional[:class:`int`] + The container's ID. + """ + from .container import Container + + container = Container(*items, id=id) + + return self.add_item(container) + + def add_section( + self, + *items: ViewItem[V], + accessory: ViewItem[V], + id: int | None = None, + ) -> Self: + """Adds a :class:`Section` to the view. + + To append a pre-existing :class:`Section`, use the + :meth:`add_item` method, instead. + + Parameters + ---------- + *items: :class:`ViewItem` + The items contained in this section, up to 3. + Currently only supports :class:`~discord.ui.TextDisplay`. + accessory: Optional[:class:`ViewItem`] + The section's accessory. This is displayed in the top right of the section. + Currently only supports :class:`~discord.ui.Button` and :class:`~discord.ui.Thumbnail`. + id: Optional[:class:`int`] + The section's ID. + """ + from .section import Section + + section = Section(*items, accessory=accessory, id=id) + + return self.add_item(section) + + def add_text(self, content: str, id: int | None = None) -> Self: + """Adds a :class:`TextDisplay` to the view. + + Parameters + ---------- + content: :class:`str` + The content of the TextDisplay + id: Optiona[:class:`int`] + The text displays' ID. + """ + from .text_display import TextDisplay + + text = TextDisplay(content, id=id) + + return self.add_item(text) + + def add_gallery( + self, + *items: MediaGalleryItem, + id: int | None = None, + ) -> Self: + """Adds a :class:`MediaGallery` to the view. + + To append a pre-existing :class:`MediaGallery`, use :meth:`add_item` instead. + + Parameters + ---------- + *items: :class:`MediaGalleryItem` + The media this gallery contains. + id: Optiona[:class:`int`] + The gallery's ID. + """ + from .media_gallery import MediaGallery + + g = MediaGallery(*items, id=id) + + return self.add_item(g) + + def add_file(self, url: str, spoiler: bool = False, id: int | None = None) -> Self: + """Adds a :class:`TextDisplay` to the view. + + Parameters + ---------- + url: :class:`str` + The URL of this file's media. This must be an ``attachment://`` URL that references a :class:`~discord.File`. + spoiler: Optional[:class:`bool`] + Whether the file has the spoiler overlay. Defaults to ``False``. + id: Optiona[:class:`int`] + The file's ID. + """ + from .file import File + + f = File(url, spoiler=spoiler, id=id) + + return self.add_item(f) + + def add_separator( + self, + *, + divider: bool = True, + spacing: SeparatorSpacingSize = SeparatorSpacingSize.small, + id: int | None = None, + ) -> Self: + """Adds a :class:`Separator` to the container. + + Parameters + ---------- + divider: :class:`bool` + Whether the separator is a divider. Defaults to ``True``. + spacing: :class:`~discord.SeparatorSpacingSize` + The spacing size of the separator. Defaults to :attr:`~discord.SeparatorSpacingSize.small`. + id: Optional[:class:`int`] + The separator's ID. + """ + from .separator import Separator + + s = Separator(divider=divider, spacing=spacing, id=id) + + return self.add_item(s) + def refresh(self, components: list[Component]): # Refreshes view data using discord's values # Assumes the components and items are identical From a054102710b9ef58533ba7a911e00459ef7ff3e8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 21:52:51 +0000 Subject: [PATCH 042/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/container.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 7a2e3c8563..a9271b7995 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -27,9 +27,8 @@ from typing import TYPE_CHECKING, Iterator, TypeVar from ..colour import Colour -from ..components import MediaGalleryItem from ..components import Container as ContainerComponent -from ..components import _component_factory +from ..components import MediaGalleryItem, _component_factory from ..enums import ComponentType, SeparatorSpacingSize from ..utils import find, get from .action_row import ActionRow From 272898255d1c45c3300492ade2a1505c73cd3131 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 16 Jan 2026 19:04:51 -0500 Subject: [PATCH 043/117] adjust underlying order --- discord/ui/button.py | 2 +- discord/ui/file_upload.py | 2 +- discord/ui/input_text.py | 6 +++--- discord/ui/select.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/discord/ui/button.py b/discord/ui/button.py index da0500df9e..fadfdb5ef1 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -147,6 +147,7 @@ def __init__( f" {emoji.__class__}" ) + self.row = row self._underlying = self._generate_underlying( custom_id=custom_id, url=url, @@ -157,7 +158,6 @@ def __init__( sku_id=sku_id, id=id, ) - self.row = row def _generate_underlying( self, diff --git a/discord/ui/file_upload.py b/discord/ui/file_upload.py index 6ef3205eaa..ff03185cea 100644 --- a/discord/ui/file_upload.py +++ b/discord/ui/file_upload.py @@ -65,6 +65,7 @@ def __init__( if not isinstance(required, bool): raise TypeError(f"required must be bool not {required.__class__.__name__}") # type: ignore custom_id = os.urandom(16).hex() if custom_id is None else custom_id + self._attachments: list[Attachment] | None = None self._underlying: FileUploadComponent = self._generate_underlying( type=ComponentType.file_upload, @@ -74,7 +75,6 @@ def __init__( required=required, id=id, ) - self._attachments: list[Attachment] | None = None def __repr__(self) -> str: attrs = " ".join( diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index 920e15e4a0..47470b3927 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -116,6 +116,9 @@ def __init__( f"expected custom_id to be str, not {custom_id.__class__.__name__}" ) custom_id = os.urandom(16).hex() if custom_id is None else custom_id + self._input_value = False + self.row = row + self._rendered_row: int | None = None self._underlying = self._generate_underlying( style=style, @@ -128,9 +131,6 @@ def __init__( value=value, id=id, ) - self._input_value = False - self.row = row - self._rendered_row: int | None = None def __repr__(self) -> str: attrs = " ".join( diff --git a/discord/ui/select.py b/discord/ui/select.py index 4e19f45240..842edb9f1b 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -276,6 +276,7 @@ def __init__( self._provided_custom_id = custom_id is not None custom_id = os.urandom(16).hex() if custom_id is None else custom_id + self.row = row self._underlying: SelectMenu = self._generate_underlying( custom_id=custom_id, type=select_type, @@ -289,7 +290,6 @@ def __init__( required=required, default_values=self._handle_default_values(default_values, select_type), ) - self.row = row def _generate_underlying( self, From 534f743aa483a33f0a4288507784367db5fa1a57 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 16 Jan 2026 19:07:07 -0500 Subject: [PATCH 044/117] fix fileupload --- discord/ui/file_upload.py | 1 - 1 file changed, 1 deletion(-) diff --git a/discord/ui/file_upload.py b/discord/ui/file_upload.py index ff03185cea..0ab76a5062 100644 --- a/discord/ui/file_upload.py +++ b/discord/ui/file_upload.py @@ -68,7 +68,6 @@ def __init__( self._attachments: list[Attachment] | None = None self._underlying: FileUploadComponent = self._generate_underlying( - type=ComponentType.file_upload, custom_id=custom_id, min_values=min_values, max_values=max_values, From 9db263237abae265f86ae519d6742d3971615ac7 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 16 Jan 2026 19:16:19 -0500 Subject: [PATCH 045/117] misc --- discord/ui/container.py | 4 ++-- discord/ui/select.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index a9271b7995..0ceb231bd9 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -263,9 +263,9 @@ def add_row( The action row's ID. """ - a = ActionRow(*items, id=id) + row = ActionRow(*items, id=id) - return self.add_item(a) + return self.add_item(row) def add_section( self, diff --git a/discord/ui/select.py b/discord/ui/select.py index 842edb9f1b..2c24cd18fd 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -319,7 +319,7 @@ def _generate_underlying( ), id=id or self.id, required=required if required is not None else self.required, - default_values=default_values or self.default_values, + default_values=default_values or self.default_values or [], ) def _handle_default_values( From d6e287d0b76701b3677adc34657d8f378e0342c7 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 16 Jan 2026 19:19:13 -0500 Subject: [PATCH 046/117] view.add_row --- discord/ui/view.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index b24b598ad6..b7c6e6214d 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -1001,10 +1001,11 @@ def add_row( id: Optiona[:class:`int`] The action row's ID. """ + from .action_row import ActionRow - a = ActionRow(*items, id=id) + row = ActionRow(*items, id=id) - return self.add_item(a) + return self.add_item(row) def add_container( self, From 6e5507ab3d5b05e9d90f89eabb71008e21927d0f Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:16:27 -0500 Subject: [PATCH 047/117] misc fixes --- discord/ui/container.py | 5 +++++ discord/ui/label.py | 5 +++++ discord/ui/media_gallery.py | 26 +++++++++++++++++++++----- discord/ui/section.py | 7 +++++++ discord/ui/view.py | 8 ++++---- 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 0ceb231bd9..58d21f74c8 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -78,6 +78,11 @@ class Container(ViewItem[V]): Whether this container has the spoiler overlay. id: Optional[:class:`int`] The container's ID. + + Attributes + ---------- + items: List[:class:`ViewItem`] + The list of items in this container. """ __item_repr_attributes__: tuple[str, ...] = ( diff --git a/discord/ui/label.py b/discord/ui/label.py index 919a2aa1e2..8c11c8dbeb 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -74,6 +74,11 @@ class Label(ModalItem[M]): The description for this label. Must be 100 characters or fewer. id: Optional[:class:`int`] The label's ID. + + Attributes + ---------- + item: :class:`ViewItem` + The label's attached item. """ __item_repr_attributes__: tuple[str, ...] = ( diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index a81873677e..fc91ede447 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -51,10 +51,15 @@ class MediaGallery(ViewItem[V]): Parameters ---------- - *items: :class:`MediaGalleryItem` + *items: :class:`~discord.MediaGalleryItem` The initial items contained in this gallery, up to 10. id: Optional[:class:`int`] The gallery's ID. + + Attributes + ---------- + items: List[:class:`~discord.MediaGalleryItem`] + The list of media items in this gallery. """ __item_repr_attributes__: tuple[str, ...] = ( @@ -78,9 +83,20 @@ def _generate_underlying( ) @property - def items(self): + def items(self) -> list[MediaGalleryItem]: + """The list of media items in this gallery.""" return self.underlying.items + @items.setter + def items(self, value: list[MediaGalleryItem]) -> None: + if len(value) > 10: + raise ValueError("may not set more than 10 items in a gallery.") + + if not all(isinstance(i, MediaGalleryItem) for i in value): + raise TypeError(f"items must be a list of MediaGalleryItem, not {i.__class__!r}") + + self.underlying.items = value + def append_item(self, item: MediaGalleryItem) -> Self: """Adds a :attr:`MediaGalleryItem` to the gallery. @@ -98,7 +114,7 @@ def append_item(self, item: MediaGalleryItem) -> Self: """ if len(self.items) >= 10: - raise ValueError("maximum number of children exceeded") + raise ValueError("maximum number of items exceeded") if not isinstance(item, MediaGalleryItem): raise TypeError(f"expected MediaGalleryItem not {item.__class__!r}") @@ -165,11 +181,11 @@ def replace_item(self, index: int, new_item: MediaGalleryItem) -> Self: if not isinstance(new_item, MediaGalleryItem): raise TypeError(f"expected MediaGalleryItem not {new_item.__class__!r}") - self.items[index] = new_item + self._underlying.items[index] = new_item return self def to_component_dict(self) -> MediaGalleryComponentPayload: - self.underlying = self._generate_underlying() + self._underlying = self._generate_underlying() return super().to_component_dict() @classmethod diff --git a/discord/ui/section.py b/discord/ui/section.py index a2d62da642..012e1bec08 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -66,6 +66,13 @@ class Section(ViewItem[V]): Sections must have an accessory attached before being sent. id: Optional[:class:`int`] The section's ID. + + Attributes + ---------- + items: List[:class:`ViewItem`] + The list of items in this section. + accessory: :class:`ViewItem` + The section's accessory, displayed in the top right of the section. """ __item_repr_attributes__: tuple[str, ...] = ( diff --git a/discord/ui/view.py b/discord/ui/view.py index b7c6e6214d..93d186de06 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -547,6 +547,8 @@ class View(BaseView): timeout: Optional[:class:`float`] Timeout in seconds from last interaction with the UI before no longer accepting input. Defaults to 180.0. If ``None`` then there is no timeout. + store: Optional[:class:`bool`] + Whether this view should be stored for callback listening. Setting it to ``False`` will ignore item callbacks and prevent their values from being refreshed. Defaults to ``True``. Attributes ---------- @@ -563,8 +565,6 @@ class View(BaseView): parent: Optional[:class:`.Interaction`] The parent interaction which this view was sent from. If ``None`` then the view was not sent using :meth:`InteractionResponse.send_message`. - store: Optional[:class:`bool`] - Whether this view should be stored for callback listening. Setting it to ``False`` will ignore item callbacks and prevent their values from being refreshed. Defaults to ``True``. """ __view_children_items__: ClassVar[list[ItemCallbackType]] = [] @@ -783,6 +783,8 @@ class DesignerView(BaseView): timeout: Optional[:class:`float`] Timeout in seconds from last interaction with the UI before no longer accepting input. Defaults to 180.0. If ``None`` then there is no timeout. + store: Optional[:class:`bool`] + Whether this view should be stored for callback listening. Setting it to ``False`` will ignore item callbacks and prevent their values from being refreshed. Defaults to ``True``. Attributes ---------- @@ -799,8 +801,6 @@ class DesignerView(BaseView): parent: Optional[:class:`.Interaction`] The parent interaction which this view was sent from. If ``None`` then the view was not sent using :meth:`InteractionResponse.send_message`. - store: Optional[:class:`bool`] - Whether this view should be stored for callback listening. Setting it to ``False`` will ignore item callbacks and prevent their values from being refreshed. Defaults to ``True``. """ MAX_ITEMS: int = 40 From 01e95f8a875dbbc77653a97a6a0ad467758f733b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:16:57 +0000 Subject: [PATCH 048/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/media_gallery.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index fc91ede447..45d99f87d9 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -93,8 +93,10 @@ def items(self, value: list[MediaGalleryItem]) -> None: raise ValueError("may not set more than 10 items in a gallery.") if not all(isinstance(i, MediaGalleryItem) for i in value): - raise TypeError(f"items must be a list of MediaGalleryItem, not {i.__class__!r}") - + raise TypeError( + f"items must be a list of MediaGalleryItem, not {i.__class__!r}" + ) + self.underlying.items = value def append_item(self, item: MediaGalleryItem) -> Self: From 0678073ad075d263217fbb213c56bf4314d709c8 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:04:44 -0500 Subject: [PATCH 049/117] fix --- discord/ui/media_gallery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 45d99f87d9..76bab9ffd2 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -94,7 +94,7 @@ def items(self, value: list[MediaGalleryItem]) -> None: if not all(isinstance(i, MediaGalleryItem) for i in value): raise TypeError( - f"items must be a list of MediaGalleryItem, not {i.__class__!r}" + f"items must be a list of MediaGalleryItem." ) self.underlying.items = value From 2a4e4d51668b5359a0991e4d6ca90485ccf0d890 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:23:29 -0500 Subject: [PATCH 050/117] adjust legacy item attributes --- discord/ui/button.py | 46 ++++++++++++++++++++++++++++++++++++++++++++ discord/ui/item.py | 36 ++-------------------------------- discord/ui/select.py | 42 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 34 deletions(-) diff --git a/discord/ui/button.py b/discord/ui/button.py index fadfdb5ef1..002dc3d041 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -110,6 +110,8 @@ def __init__( row: int | None = None, id: int | None = None, ): + self._row: int | None = None + self._rendered_row: int | None = None super().__init__() if label and len(str(label)) > 80: raise ValueError("label must be 80 characters or fewer") @@ -273,6 +275,50 @@ def sku_id(self, value: int | None): # type: ignore else: raise TypeError(f"expected int or None, received {value.__class__} instead") + @property + def width(self) -> int: + """Gets the width of the item in the UI layout. + + The width determines how much horizontal space this item occupies within its row. + This attribute is not compatible with :class:`discord.ui.DesignerView`. + + Returns + ------- + :class:`int` + The width of the item. Buttons have a width of 1. + """ + return 1 + + @property + def row(self) -> int | None: + """Gets or sets the row position of this item within its parent view. + + The row position determines the vertical placement of the item in the UI. + The value must be an integer between 0 and 4 (inclusive), or ``None`` to indicate + that no specific row is set. + This attribute is not compatible with :class:`discord.ui.DesignerView`. + + Returns + ------- + Optional[:class:`int`] + The row position of the item, or ``None`` if not explicitly set. + + Raises + ------ + ValueError + If the row value is not ``None`` and is outside the range [0, 4]. + """ + return self._row + + @row.setter + def row(self, value: int | None): + if value is None: + self._row = None + elif 5 > value >= 0: + self._row = value + else: + raise ValueError("row cannot be negative or greater than or equal to 5") + @classmethod def from_component(cls: type[B], button: ButtonComponent) -> B: return cls( diff --git a/discord/ui/item.py b/discord/ui/item.py index a38148bb19..42f30db3e3 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -165,50 +165,18 @@ class ViewItem(Item[V]): def __init__(self): super().__init__() self._view: V | None = None - self._row: int | None = None - self._rendered_row: int | None = None self.parent: ViewItem | BaseView | None = None @property def row(self) -> int | None: - """Gets or sets the row position of this item within its parent view. - - The row position determines the vertical placement of the item in the UI. - The value must be an integer between 0 and 39 (inclusive), or ``None`` to indicate - that no specific row is set. - - Returns - ------- - Optional[:class:`int`] - The row position of the item, or ``None`` if not explicitly set. - - Raises - ------ - ValueError - If the row value is not ``None`` and is outside the range [0, 39]. - """ - return self._row + return None @row.setter def row(self, value: int | None): - if value is None: - self._row = None - elif 39 > value >= 0: - self._row = value - else: - raise ValueError("row cannot be negative or greater than or equal to 39") + raise NotImplementedError @property def width(self) -> int: - """Gets the width of the item in the UI layout. - - The width determines how much horizontal space this item occupies within its row. - - Returns - ------- - :class:`int` - The width of the item. Defaults to 1. - """ return 1 @property diff --git a/discord/ui/select.py b/discord/ui/select.py index 2c24cd18fd..b11a458d23 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -252,6 +252,8 @@ def __init__( required: bool | None = None, default_values: Sequence[SelectDefaultValue | ST] | None = None, ) -> None: + self._row: int | None = None + self._rendered_row: int | None = None if options and select_type is not ComponentType.string_select: raise InvalidArgument("options parameter is only valid for string selects") if channel_types and select_type is not ComponentType.channel_select: @@ -735,8 +737,48 @@ def values(self) -> list[ST]: @property def width(self) -> int: + """Gets the width of the item in the UI layout. + + The width determines how much horizontal space this item occupies within its row. + This attribute is not compatible with :class:`discord.ui.DesignerView`. + + Returns + ------- + :class:`int` + The width of the item. Select menus have a width of 5. + """ return 5 + @property + def row(self) -> int | None: + """Gets or sets the row position of this item within its parent view. + + The row position determines the vertical placement of the item in the UI. + The value must be an integer between 0 and 4 (inclusive), or ``None`` to indicate + that no specific row is set. + This attribute is not compatible with :class:`discord.ui.DesignerView`. + + Returns + ------- + Optional[:class:`int`] + The row position of the item, or ``None`` if not explicitly set. + + Raises + ------ + ValueError + If the row value is not ``None`` and is outside the range [0, 4]. + """ + return self._row + + @row.setter + def row(self, value: int | None): + if value is None: + self._row = None + elif 5 > value >= 0: + self._row = value + else: + raise ValueError("row cannot be negative or greater than or equal to 5") + def to_component_dict(self) -> SelectMenuPayload: return super().to_component_dict() From 99ecd2cfe6f74db1bb22c4288b55da5f90e6ca51 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:00:22 +0000 Subject: [PATCH 051/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/media_gallery.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 76bab9ffd2..009517b384 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -93,9 +93,7 @@ def items(self, value: list[MediaGalleryItem]) -> None: raise ValueError("may not set more than 10 items in a gallery.") if not all(isinstance(i, MediaGalleryItem) for i in value): - raise TypeError( - f"items must be a list of MediaGalleryItem." - ) + raise TypeError(f"items must be a list of MediaGalleryItem.") self.underlying.items = value From 23b774375d960da3200e97548ec74f7bb8f76377 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:19:09 -0500 Subject: [PATCH 052/117] width docs adjustment --- discord/ui/button.py | 1 - discord/ui/select.py | 1 - 2 files changed, 2 deletions(-) diff --git a/discord/ui/button.py b/discord/ui/button.py index 002dc3d041..0570f790ec 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -280,7 +280,6 @@ def width(self) -> int: """Gets the width of the item in the UI layout. The width determines how much horizontal space this item occupies within its row. - This attribute is not compatible with :class:`discord.ui.DesignerView`. Returns ------- diff --git a/discord/ui/select.py b/discord/ui/select.py index b11a458d23..b828bc3e39 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -740,7 +740,6 @@ def width(self) -> int: """Gets the width of the item in the UI layout. The width determines how much horizontal space this item occupies within its row. - This attribute is not compatible with :class:`discord.ui.DesignerView`. Returns ------- From 23be55be457c67f39d5b73b76767a0e7cdae8610 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 14:20:39 +0000 Subject: [PATCH 053/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 0cfae1f456..9ccd58a075 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -59,8 +59,8 @@ from ..components import Thumbnail as ThumbnailComponent from ..components import _component_factory from ..enums import ChannelType, SeparatorSpacingSize -from ..utils import find from ..errors import Forbidden, NotFound +from ..utils import find from .core import ItemInterface from .item import ItemCallbackType, ViewItem From 12caf985fb74c1335a3114b1135353949d59f411 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 20 Feb 2026 05:23:31 +0000 Subject: [PATCH 054/117] Update CHANGELOG.md Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> Signed-off-by: Nelo <41271523+NeloBlivion@users.noreply.github.com> --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92b9a91792..98b4a8f067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,6 @@ These changes are available on the `master` branch, but have not yet been releas - Added `replace_item` to `DesignerView`, `Section`, `Container`, `ActionRow`, & `MediaGallery` ([#3032](https://github.com/Pycord-Development/pycord/pull/3032)) -- Added `.extension` attribute to emojis to get their file extension. - Added `.extension` attribute to the `AppEmoji` and `GuildEmoji` classes. ([#3055](https://github.com/Pycord-Development/pycord/pull/3055)) - Added the ability to compare instances of `PrimaryGuild`. From db9a63cad609386ee542a72d9d0e2309a5ce50ca Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:50:43 -0500 Subject: [PATCH 055/117] Message.get_view --- discord/message.py | 22 ++++++++++++++++++++++ discord/state.py | 3 +++ discord/ui/view.py | 26 +++++++++++++++++++------- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/discord/message.py b/discord/message.py index cf9a6ea27b..04d4d0f786 100644 --- a/discord/message.py +++ b/discord/message.py @@ -61,6 +61,7 @@ from .sticker import StickerItem from .threads import Thread from .utils import MISSING, escape_mentions, find, warn_deprecated +from .ui.view import DesignerView if TYPE_CHECKING: from .abc import ( @@ -2345,6 +2346,27 @@ def get_component(self, id: str | int) -> Component | None: return component return None + def get_view(self, cls: BaseView = DesignerView) -> DesignerView | BaseView | None: + """Retrieve this message's view from the ViewStore. If there is no stored view, a new view will be returned if :attr:`components` is not empty. + + Parameters + ---------- + cls + The class that will be used to generate the new view. + By default, this is :class:`discord.ui.DesignerView`. Should a custom + class be provided, it must inherit from :class:`discord.ui.BaseView` + and properly implement ``from_message``. + + Returns + ------- + Optional[Union[:class:`discord.ui.DesignerView`, :class:`discord.ui.BaseView`]] + The view belonging to this message, if it exists or there are components available to create a new view. + """ + v = self._state.get_message_view(self.id) + if not v and self.components: + v = DesignerView.from_message(self) + return v + class PartialMessage(Hashable): """Represents a partial message to aid with working messages when only diff --git a/discord/state.py b/discord/state.py index 92d22760de..a695252eca 100644 --- a/discord/state.py +++ b/discord/state.py @@ -417,6 +417,9 @@ def store_view(self, view: BaseView, message_id: int | None = None) -> None: def purge_message_view(self, message_id: int) -> None: self._view_store.remove_message_view(message_id) + def get_message_view(self, message_id: int) -> BaseView | None: + self._view_store.get_message_view(message_id) + def store_modal(self, modal: BaseModal, message_id: int) -> None: self._modal_store.add_modal(modal, message_id) diff --git a/discord/ui/view.py b/discord/ui/view.py index c39cd38954..da42d02337 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -530,6 +530,15 @@ def message(self): def message(self, value): self._message = value + @classmethod + def from_message( + cls, message: Message, /, *, timeout: float | None = 180.0 + ) -> BaseView: + view = cls(timeout=timeout) + for component in message.components: + view.add_item(_component_to_item(component)) + return view + class View(BaseView): """Represents a legacy UI view for V1 components :class:`~discord.ui.Button` and :class:`~discord.ui.Select`. @@ -1182,7 +1191,7 @@ def persistent_views(self) -> Sequence[BaseView]: } return list(views.values()) - def __verify_integrity(self): + def __verify_integrity(self) -> None: to_remove: list[tuple[int, int | None, str]] = [] for k, (view, _) in self._views.items(): if view.is_finished(): @@ -1191,7 +1200,7 @@ def __verify_integrity(self): for k in to_remove: del self._views[k] - def add_view(self, view: BaseView, message_id: int | None = None): + def add_view(self, view: BaseView, message_id: int | None = None) -> None: if not view._store: return self.__verify_integrity() @@ -1207,7 +1216,7 @@ def add_view(self, view: BaseView, message_id: int | None = None): if message_id is not None: self._synced_message_views[message_id] = view - def remove_view(self, view: BaseView): + def remove_view(self, view: BaseView) -> None: for item in view.walk_children(): if item.is_storable(): self._views.pop((item.type.value, item.custom_id), None) # type: ignore @@ -1217,10 +1226,13 @@ def remove_view(self, view: BaseView): self.remove_message_view(key) break - def remove_message_view(self, message_id): + def remove_message_view(self, message_id: int) -> None: del self._synced_message_views[message_id] - def dispatch(self, component_type: int, custom_id: str, interaction: Interaction): + def get_message_view(self, message_id: int) -> BaseView | None: + return self._synced_message_views.get(message_id) + + def dispatch(self, component_type: int, custom_id: str, interaction: Interaction) -> None: self.__verify_integrity() message_id: int | None = interaction.message and interaction.message.id key = (component_type, message_id, custom_id) @@ -1237,13 +1249,13 @@ def dispatch(self, component_type: int, custom_id: str, interaction: Interaction item.refresh_state(interaction) view._dispatch_item(item, interaction) - def is_message_tracked(self, message_id: int): + def is_message_tracked(self, message_id: int) -> bool: return message_id in self._synced_message_views def remove_message_tracking(self, message_id: int) -> BaseView | None: return self._synced_message_views.pop(message_id, None) - def update_from_message(self, message_id: int, components: list[ComponentPayload]): + def update_from_message(self, message_id: int, components: list[ComponentPayload]) -> None: # pre-req: is_message_tracked == true view = self._synced_message_views[message_id] components = [_component_factory(d, state=self._state) for d in components] From 66054a0790b9456a07d4c1a08ef07f1af8e2b39b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:51:11 +0000 Subject: [PATCH 056/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/message.py | 2 +- discord/ui/view.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/discord/message.py b/discord/message.py index 04d4d0f786..a372a29798 100644 --- a/discord/message.py +++ b/discord/message.py @@ -60,8 +60,8 @@ from .reaction import Reaction from .sticker import StickerItem from .threads import Thread -from .utils import MISSING, escape_mentions, find, warn_deprecated from .ui.view import DesignerView +from .utils import MISSING, escape_mentions, find, warn_deprecated if TYPE_CHECKING: from .abc import ( diff --git a/discord/ui/view.py b/discord/ui/view.py index da42d02337..3dbce0bbbe 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -1232,7 +1232,9 @@ def remove_message_view(self, message_id: int) -> None: def get_message_view(self, message_id: int) -> BaseView | None: return self._synced_message_views.get(message_id) - def dispatch(self, component_type: int, custom_id: str, interaction: Interaction) -> None: + def dispatch( + self, component_type: int, custom_id: str, interaction: Interaction + ) -> None: self.__verify_integrity() message_id: int | None = interaction.message and interaction.message.id key = (component_type, message_id, custom_id) @@ -1255,7 +1257,9 @@ def is_message_tracked(self, message_id: int) -> bool: def remove_message_tracking(self, message_id: int) -> BaseView | None: 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: list[ComponentPayload] + ) -> None: # pre-req: is_message_tracked == true view = self._synced_message_views[message_id] components = [_component_factory(d, state=self._state) for d in components] From 6d30016ddd0f08b74288e26365d7de5da512f675 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:41:26 -0500 Subject: [PATCH 057/117] adjust imports --- discord/message.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/message.py b/discord/message.py index a372a29798..b7ba54ba97 100644 --- a/discord/message.py +++ b/discord/message.py @@ -60,7 +60,6 @@ from .reaction import Reaction from .sticker import StickerItem from .threads import Thread -from .ui.view import DesignerView from .utils import MISSING, escape_mentions, find, warn_deprecated if TYPE_CHECKING: @@ -94,7 +93,7 @@ from .types.snowflake import SnowflakeList from .types.threads import ThreadArchiveDuration from .types.user import User as UserPayload - from .ui.view import BaseView + from .ui.view import BaseView, DesignerView from .user import User MR = TypeVar("MR", bound="MessageReference") @@ -2364,6 +2363,7 @@ class be provided, it must inherit from :class:`discord.ui.BaseView` """ v = self._state.get_message_view(self.id) if not v and self.components: + from .ui.view import DesignerView v = DesignerView.from_message(self) return v From 065e1a1c58bff831cf946baa613ead4bf3cdd173 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:41:56 +0000 Subject: [PATCH 058/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/message.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/message.py b/discord/message.py index b7ba54ba97..ea2815da8a 100644 --- a/discord/message.py +++ b/discord/message.py @@ -2364,6 +2364,7 @@ class be provided, it must inherit from :class:`discord.ui.BaseView` v = self._state.get_message_view(self.id) if not v and self.components: from .ui.view import DesignerView + v = DesignerView.from_message(self) return v From 68e8252519019d68f4eff1234c05d8bb3f776a9c Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:46:08 -0500 Subject: [PATCH 059/117] sure whatever i hate this --- discord/message.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/discord/message.py b/discord/message.py index ea2815da8a..54847eb3ad 100644 --- a/discord/message.py +++ b/discord/message.py @@ -2345,7 +2345,7 @@ def get_component(self, id: str | int) -> Component | None: return component return None - def get_view(self, cls: BaseView = DesignerView) -> DesignerView | BaseView | None: + def get_view(self, cls: BaseView | None = None) -> DesignerView | BaseView | None: """Retrieve this message's view from the ViewStore. If there is no stored view, a new view will be returned if :attr:`components` is not empty. Parameters @@ -2363,9 +2363,10 @@ class be provided, it must inherit from :class:`discord.ui.BaseView` """ v = self._state.get_message_view(self.id) if not v and self.components: - from .ui.view import DesignerView - - v = DesignerView.from_message(self) + if not cls: + from .ui.view import DesignerView + cls = DesignerView + v = cls.from_message(self) return v From 4c89957cdf0ad7ff11ce10dab08c13e453ae47b9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:47:59 +0000 Subject: [PATCH 060/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/message.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/message.py b/discord/message.py index 54847eb3ad..aeb28e82ab 100644 --- a/discord/message.py +++ b/discord/message.py @@ -2365,6 +2365,7 @@ class be provided, it must inherit from :class:`discord.ui.BaseView` if not v and self.components: if not cls: from .ui.view import DesignerView + cls = DesignerView v = cls.from_message(self) return v From c222ded7b1a6fde506d8944aee39fc707019dff0 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:35:41 -0400 Subject: [PATCH 061/117] doc fixes --- discord/ui/media_gallery.py | 5 ----- discord/ui/radio_group.py | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 009517b384..dfe54e10d8 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -55,11 +55,6 @@ class MediaGallery(ViewItem[V]): The initial items contained in this gallery, up to 10. id: Optional[:class:`int`] The gallery's ID. - - Attributes - ---------- - items: List[:class:`~discord.MediaGalleryItem`] - The list of media items in this gallery. """ __item_repr_attributes__: tuple[str, ...] = ( diff --git a/discord/ui/radio_group.py b/discord/ui/radio_group.py index 7a01501776..9297991a68 100644 --- a/discord/ui/radio_group.py +++ b/discord/ui/radio_group.py @@ -47,7 +47,7 @@ class RadioGroup(ModalItem): .. versionadded:: 2.8 - Attributes + Parameters ---------- custom_id: Optional[:class:`str`] The ID of the radio group that gets received during an interaction. From 8dfd1dcd7f446d12fdde8c13a4d1ee3f496a9a99 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 03:57:41 -0400 Subject: [PATCH 062/117] types --- discord/ui/view.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 0c7be24381..40dd42f5b4 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -864,7 +864,7 @@ def items(self, value: list[ViewItem[V]]) -> None: @classmethod def from_message( cls, message: Message, /, *, timeout: float | None = 180.0 - ) -> View: + ) -> DesignerView: """Converts a message's components into a :class:`DesignerView`. The :attr:`.Message.components` of a message are read-only @@ -897,7 +897,7 @@ def from_dict( /, *, timeout: float | None = 180.0, - ) -> View: + ) -> DesignerView: """Converts a list of component dicts into a :class:`DesignerView`. Parameters From d44dbf70ef0c66add9c13e8aa4d81ec27d240202 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 04:17:58 -0400 Subject: [PATCH 063/117] remove silly check --- discord/ui/core.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/discord/ui/core.py b/discord/ui/core.py index 2c00309710..86a2b2c64f 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -150,8 +150,6 @@ def get_item(self, custom_id: str | int | None = None, **attrs: Any) -> Item | N Optional[:class:`Item`] The item with the matching ``custom_id``, ``id``, or ``attrs`` if it exists. """ - if not (custom_id or attrs): - return None child = None if custom_id: attr = "id" if isinstance(custom_id, int) else "custom_id" From e7399fc95630ea86d9a7f6cbbd6142afbb325b3c Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 04:46:03 -0400 Subject: [PATCH 064/117] complete base add_item --- discord/ui/section.py | 4 +++- discord/ui/view.py | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index ddf2fed6a3..2099640771 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -200,8 +200,10 @@ def replace_item( The new item to insert into the section. """ + if not original_item: + raise TypeError(f"expected original_item to be a valid ViewItem, str, or int, not {new_item.__class__!r}") if not isinstance(new_item, ViewItem): - raise TypeError(f"expected ViewItem not {new_item.__class__!r}") + raise TypeError(f"expected new_item to be ViewItem, not {new_item.__class__!r}") if isinstance(original_item, (str, int)): original_item = self.get_item(original_item) diff --git a/discord/ui/view.py b/discord/ui/view.py index 40dd42f5b4..0ab690ee90 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -968,10 +968,43 @@ def add_item( ): raise ValueError("Can only specify one of before, after, and index.") + if len(self.children) >= self.MAX_ITEMS: + raise ValueError("maximum number of children exceeded") + + if not isinstance(item, ViewItem): + raise TypeError(f"expected item to be ViewItem, not {item.__class__!r}") + if isinstance(item.underlying, (SelectComponent, ButtonComponent)): raise ValueError( f"cannot add Select or Button to DesignerView directly. Use ActionRow instead." ) + if into and isinstance(into, (str, int)): + parent = self.get_item(into) + if not parent: + raise ValueError(f"Could not find into in view.") + else: + parent = into or self + + if before or after: + ref = parent.get_item(before or after) + if ref.parent is parent: + try: + i = parent.items.index(ref) + except: + raise ValueError(f"Could not find before or after in view.") + item.parent = parent + if before: + parent.items.insert(i, item) + else: + parent.items.insert(i+1, item) + else: + ref.parent.add_item(item, before=before, after=after, into=into) + return self + + elif index is not None: + item.parent = parent + parent.items.insert(index, item) + return self super().add_item(item) return self @@ -995,8 +1028,10 @@ def replace_item( The view instance. """ + if not original_item: + raise TypeError(f"expected original_item to be a valid ViewItem, str, or int, not {new_item.__class__!r}") if not isinstance(new_item, ViewItem): - raise TypeError(f"expected ViewItem not {new_item.__class__!r}") + raise TypeError(f"expected new_item to be ViewItem, not {new_item.__class__!r}") if isinstance(original_item, (str, int)): original_item = self.get_item(original_item) From 33c2b4a8cf21561162c9c31a33303c5b3adc5096 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 08:46:32 +0000 Subject: [PATCH 065/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/section.py | 8 ++++++-- discord/ui/view.py | 10 +++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index 2099640771..ec032ffe85 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -201,9 +201,13 @@ def replace_item( """ if not original_item: - raise TypeError(f"expected original_item to be a valid ViewItem, str, or int, not {new_item.__class__!r}") + raise TypeError( + f"expected original_item to be a valid ViewItem, str, or int, not {new_item.__class__!r}" + ) if not isinstance(new_item, ViewItem): - raise TypeError(f"expected new_item to be ViewItem, not {new_item.__class__!r}") + raise TypeError( + f"expected new_item to be ViewItem, not {new_item.__class__!r}" + ) if isinstance(original_item, (str, int)): original_item = self.get_item(original_item) diff --git a/discord/ui/view.py b/discord/ui/view.py index 0ab690ee90..68d8920b7b 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -996,7 +996,7 @@ def add_item( if before: parent.items.insert(i, item) else: - parent.items.insert(i+1, item) + parent.items.insert(i + 1, item) else: ref.parent.add_item(item, before=before, after=after, into=into) return self @@ -1029,9 +1029,13 @@ def replace_item( """ if not original_item: - raise TypeError(f"expected original_item to be a valid ViewItem, str, or int, not {new_item.__class__!r}") + raise TypeError( + f"expected original_item to be a valid ViewItem, str, or int, not {new_item.__class__!r}" + ) if not isinstance(new_item, ViewItem): - raise TypeError(f"expected new_item to be ViewItem, not {new_item.__class__!r}") + raise TypeError( + f"expected new_item to be ViewItem, not {new_item.__class__!r}" + ) if isinstance(original_item, (str, int)): original_item = self.get_item(original_item) From d4f4da7808b74204a63c24428beb6c7de945f5a7 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:20:03 -0400 Subject: [PATCH 066/117] section, container, row, len --- discord/ui/action_row.py | 3 ++ discord/ui/container.py | 68 ++++++++++++++++++++++++++++++++++++---- discord/ui/core.py | 6 ++++ discord/ui/item.py | 6 ++++ discord/ui/section.py | 50 ++++++++++++++++++++++++++++- discord/ui/view.py | 14 ++++++--- 6 files changed, 135 insertions(+), 12 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 10d6f45fe8..cf638ceb1c 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -107,6 +107,9 @@ def __init__( for i in items: self.add_item(i) + def __len__(self) -> int: + return len(self.children) + @property def items(self) -> list[ViewItem]: return self.children diff --git a/discord/ui/container.py b/discord/ui/container.py index 4367873be7..9b0d6a7d1c 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -120,6 +120,9 @@ def __init__( for i in items: self.add_item(i) + def __len__(self) -> int: + return sum(len(i) for i in self.items) + def _add_component_from_item(self, item: ViewItem): self.underlying.components.append(item._generate_underlying()) @@ -146,19 +149,44 @@ def _generate_underlying( container.components.append(i._generate_underlying()) return container - def add_item(self, item: ViewItem) -> Self: + def add_item(self, + item: ViewItem, + *, + index: int | None = None, + before: ViewItem[V] | str | int | None = None, + after: ViewItem[V] | str | int | None = None, + into: ViewItem[V] | str | int | None = None, + ) -> Self: """Adds an item to the container. Parameters ---------- item: :class:`ViewItem` The item to add to the container. + index: Optional[class:`int`] + Add the new item at the specific index of :attr:`items`. Same behavior as Python's :func:`~list.insert`. + before: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **before** the specified item. If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. + after: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **after** the specified item. If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. + into: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **into** the specified item. This would be equivalent to `into.add_item(item)`, where `into` is a :class:`ViewItem`. + If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. Raises ------ TypeError A :class:`ViewItem` was not passed. """ + if ( + before + and after + or before + and (index is not None) + or after + and (index is not None) + ): + raise ValueError("Can only specify one of before, after, and index.") if not isinstance(item, ViewItem): raise TypeError(f"expected ViewItem not {item.__class__!r}") @@ -167,11 +195,39 @@ def add_item(self, item: ViewItem) -> Self: raise TypeError( f"{item.__class__!r} cannot be added directly. Use ActionRow instead." ) - - item.parent = self - - self.items.append(item) - self._add_component_from_item(item) + if into and isinstance(into, (str, int)): + parent = self.get_item(into) + if not parent: + raise ValueError(f"Could not find into in container.") + else: + parent = into or self + + if before or after: + ref = parent.get_item(before or after) + if ref.parent is parent: + try: + i = parent.items.index(ref) + except: + raise ValueError(f"Could not find before or after in container.") + item.parent = parent + if before: + parent.items.insert(i, item) + else: + parent.items.insert(i + 1, item) + else: + ref.parent.add_item(item, before=before, after=after) + self._underlying = self._generate_underlying() + return self + + elif index is not None: + item.parent = parent + parent.items.insert(index, item) + self._underlying = self._generate_underlying() + return self + + item.parent = parent + parent.items.append(item) + parent._add_component_from_item(item) return self def remove_item(self, item: ViewItem | str | int) -> Self: diff --git a/discord/ui/core.py b/discord/ui/core.py index 86a2b2c64f..a82c2fc6e9 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -87,6 +87,12 @@ def __init__( def __repr__(self) -> str: return f"<{self.__class__.__name__} timeout={self.timeout} children={len(self.children)}>" + def __len__(self) -> int: + return sum(len(i) for i in self.children) + + def __bool__(self) -> bool: + return True + async def _timeout_task_impl(self) -> None: while True: # Guard just in case someone changes the value of the timeout at runtime diff --git a/discord/ui/item.py b/discord/ui/item.py index 85132558bd..6825df2233 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -67,6 +67,12 @@ def __init__(self): self._provided_custom_id: bool = False self.parent: Item | ItemInterface | None = None + def __len__(self) -> int: + return 1 + + def __bool__(self) -> bool: + return True + def to_component_dict(self) -> dict[str, Any]: if not self.underlying: raise NotImplementedError diff --git a/discord/ui/section.py b/discord/ui/section.py index ec032ffe85..4815d4cc76 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -115,6 +115,10 @@ def __init__( for i in items: self.add_item(i) + def __len__(self) -> int: + r = sum(len(i) for i in self.items) + return (r+1) if self.accessory else r + def _add_component_from_item(self, item: ViewItem): self.underlying.components.append(item.underlying) @@ -137,13 +141,25 @@ def _generate_underlying(self, id: int | None = None) -> SectionComponent: section.accessory = self.accessory._generate_underlying() return section - def add_item(self, item: ViewItem) -> Self: + def add_item(self, + item: ViewItem, + *, + index: int | None = None, + before: ViewItem[V] | str | int | None = None, + after: ViewItem[V] | str | int | None = None, + ) -> Self: """Adds an item to the section. Parameters ---------- item: :class:`ViewItem` The item to add to the section. + index: Optional[class:`int`] + Add the new item at the specific index of :attr:`items`. Same behavior as Python's :func:`~list.insert`. + before: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **before** the specified item. If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. + after: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **after** the specified item. If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. Raises ------ @@ -152,6 +168,15 @@ def add_item(self, item: ViewItem) -> Self: ValueError Maximum number of items has been exceeded (3). """ + if ( + before + and after + or before + and (index is not None) + or after + and (index is not None) + ): + raise ValueError("Can only specify one of before, after, and index.") if len(self.items) >= 3: raise ValueError("maximum number of children exceeded") @@ -159,6 +184,29 @@ def add_item(self, item: ViewItem) -> Self: if not isinstance(item, ViewItem): raise TypeError(f"expected ViewItem not {item.__class__!r}") + if before or after: + ref = self.get_item(before or after) + if ref.parent is self: + try: + i = self.items.index(ref) + except: + raise ValueError(f"Could not find before or after in container.") + item.parent = self + if before: + self.items.insert(i, item) + else: + self.items.insert(i + 1, item) + else: + ref.parent.add_item(item, before=before, after=after) + self._underlying = self._generate_underlying() + return self + + elif index is not None: + item.parent = self + self.items.insert(index, item) + self._underlying = self._generate_underlying() + return self + item.parent = self self.items.append(item) self._add_component_from_item(item) diff --git a/discord/ui/view.py b/discord/ui/view.py index 68d8920b7b..6729b8f587 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -257,7 +257,7 @@ def add_item(self, item: ViewItem[V]) -> Self: Maximum number of children has been exceeded """ - if len(self.children) >= self.MAX_ITEMS: + if len(self) >= self.MAX_ITEMS: raise ValueError("maximum number of children exceeded") if not isinstance(item, ViewItem): @@ -954,7 +954,7 @@ def add_item( Raises ------ TypeError - An :class:`ViewItem` was not passed. + A :class:`ViewItem` was not passed. ValueError Maximum number of items has been exceeded (40) """ @@ -968,7 +968,7 @@ def add_item( ): raise ValueError("Can only specify one of before, after, and index.") - if len(self.children) >= self.MAX_ITEMS: + if len(self) >= self.MAX_ITEMS: raise ValueError("maximum number of children exceeded") if not isinstance(item, ViewItem): @@ -998,7 +998,10 @@ def add_item( else: parent.items.insert(i + 1, item) else: - ref.parent.add_item(item, before=before, after=after, into=into) + if isinstance(ref.parent.underlying, ContainerComponent): + ref.parent.add_item(item, before=before, after=after, into=into) + else: + ref.parent.add_item(item, before=before, after=after) return self elif index is not None: @@ -1006,7 +1009,8 @@ def add_item( parent.items.insert(index, item) return self - super().add_item(item) + item.parent = parent + parent.items.append(item) return self def replace_item( From 2d43d5acf7e17483351c3c521f467210946b9153 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:20:32 +0000 Subject: [PATCH 067/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/container.py | 3 ++- discord/ui/section.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 9b0d6a7d1c..890b4c8052 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -149,7 +149,8 @@ def _generate_underlying( container.components.append(i._generate_underlying()) return container - def add_item(self, + def add_item( + self, item: ViewItem, *, index: int | None = None, diff --git a/discord/ui/section.py b/discord/ui/section.py index 4815d4cc76..0d5157f526 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -117,7 +117,7 @@ def __init__( def __len__(self) -> int: r = sum(len(i) for i in self.items) - return (r+1) if self.accessory else r + return (r + 1) if self.accessory else r def _add_component_from_item(self, item: ViewItem): self.underlying.components.append(item.underlying) @@ -141,7 +141,8 @@ def _generate_underlying(self, id: int | None = None) -> SectionComponent: section.accessory = self.accessory._generate_underlying() return section - def add_item(self, + def add_item( + self, item: ViewItem, *, index: int | None = None, From ea7aa0e1f48a452d31abf49d03f34ca7c51eaffa Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:32:30 -0400 Subject: [PATCH 068/117] modal ext --- discord/ui/label.py | 4 ++-- discord/ui/modal.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/discord/ui/label.py b/discord/ui/label.py index 19fea37e17..5bdceddd2b 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -80,10 +80,10 @@ class Label(ModalItem[M]): Parameters ---------- - item: :class:`ModalItem` - The initial item attached to this label. label: :class:`str` The label text. Must be 45 characters or fewer. + item: :class:`ModalItem` + The initial item attached to this label. description: Optional[:class:`str`] The description for this label. Must be 100 characters or fewer. id: Optional[:class:`int`] diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 6bb3a21d6a..7eaf5f0068 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -448,6 +448,49 @@ def add_item(self, item: ModalItem) -> Self: super().add_item(item) return self + def add_label( + self, + label: str, + item: ModalItem = None, + *, + description: str | None = None, + id: int | None = None, + ) -> self: + """Adds a :class:`Label` to the modal. + + To append a pre-existing :class:`Label`, use :meth:`add_item` instead. + + Parameters + ---------- + label: :class:`str` + The label text. Must be 45 characters or fewer. + item: :class:`ModalItem` + The initial item attached to this label. + description: Optional[:class:`str`] + The description for this label. Must be 100 characters or fewer. + id: Optional[:class:`int`] + The label's ID. + """ + + label = Label(label, item=item, description=description, id=id) + + return self.add_item(label) + + def add_text(self, content: str, id: int | None = None) -> Self: + """Adds a :class:`TextDisplay` to the modal. + + Parameters + ---------- + content: :class:`str` + The content of the TextDisplay + id: Optiona[:class:`int`] + The text display's ID. + """ + + text = TextDisplay(content, id=id) + + return self.add_item(text) + def refresh(self, interaction: Interaction, data: list[ComponentPayload]): for component, child in zip(data, self.children): child.refresh_from_modal(interaction, component) From d414afc1d74312b2b9ce1f0cc8d2560b846b4da2 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:34:24 -0400 Subject: [PATCH 069/117] -> Self --- discord/ui/modal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 7eaf5f0068..7b757d108b 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -455,7 +455,7 @@ def add_label( *, description: str | None = None, id: int | None = None, - ) -> self: + ) -> Self: """Adds a :class:`Label` to the modal. To append a pre-existing :class:`Label`, use :meth:`add_item` instead. From 1e145646bfa2338e2c5049e6e63e433f95e977e4 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:57:44 -0400 Subject: [PATCH 070/117] improve validation --- discord/ui/action_row.py | 47 +++++++++++++++++++++++++++++++++++++--- discord/ui/container.py | 30 ++++++++++++------------- discord/ui/section.py | 26 ++++++++++------------ discord/ui/view.py | 38 ++++++++++++++++---------------- 4 files changed, 90 insertions(+), 51 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index cf638ceb1c..51abd0ea40 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -108,7 +108,7 @@ def __init__( self.add_item(i) def __len__(self) -> int: - return len(self.children) + return len(self.children) + 1 @property def items(self) -> list[ViewItem]: @@ -137,19 +137,41 @@ def _generate_underlying(self, id: int | None = None) -> ActionRowComponent: row.children.append(i._generate_underlying()) return row - def add_item(self, item: ViewItem) -> Self: + def add_item( + self, + item: ViewItem, + *, + index: int | None = None, + before: ViewItem[V] | str | int | None = None, + after: ViewItem[V] | str | int | None = None, + ) -> Self: """Adds an item to the action row. Parameters ---------- item: :class:`ViewItem` The item to add to the action row. + index: Optional[class:`int`] + Add the new item at the specific index of :attr:`children`. Same behavior as Python's :func:`~list.insert`. + before: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **before** the specified item. If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. + after: Optional[Union[:class:`ViewItem`, :class:`int`, :class:`str`]] + Add the new item **after** the specified item. If an :class:`int` is provided, the item will be detected by ``id``, otherwise by ``custom_id``. Raises ------ TypeError A :class:`ViewItem` was not passed. """ + if ( + before is not None + and after is not None + or before is not None + and (index is not None) + or after is not None + and (index is not None) + ): + raise ValueError("Can only specify one of before, after, and index.") if not isinstance(item, (Select, Button)): raise TypeError(f"expected Select or Button, not {item.__class__!r}") @@ -158,8 +180,27 @@ def add_item(self, item: ViewItem) -> Self: if self.width + item.width > 5: raise ValueError(f"Not enough space left on this ActionRow") - item.parent = self + if before is not None or after is not None: + try: + ref = self.get_item(before or after or 0) + i = self.children.index(ref) + item.parent = self + if before: + self.children.insert(i, item) + else: + self.children.insert(i + 1, item) + except: + raise ValueError(f"Could not find before or after in row.") + self._underlying = self._generate_underlying() + return self + + elif index is not None: + item.parent = self + self.children.insert(index, item) + self._underlying = self._generate_underlying() + return self + item.parent = self self.children.append(item) self._add_component_from_item(item) return self diff --git a/discord/ui/container.py b/discord/ui/container.py index 890b4c8052..eea23e5740 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -121,7 +121,7 @@ def __init__( self.add_item(i) def __len__(self) -> int: - return sum(len(i) for i in self.items) + return sum(len(i) for i in self.items) + 1 def _add_component_from_item(self, item: ViewItem): self.underlying.components.append(item._generate_underlying()) @@ -180,11 +180,11 @@ def add_item( A :class:`ViewItem` was not passed. """ if ( - before - and after - or before + before is not None + and after is not None + or before is not None and (index is not None) - or after + or after is not None and (index is not None) ): raise ValueError("Can only specify one of before, after, and index.") @@ -205,18 +205,18 @@ def add_item( if before or after: ref = parent.get_item(before or after) - if ref.parent is parent: - try: + try: + if ref.parent is parent: i = parent.items.index(ref) - except: - raise ValueError(f"Could not find before or after in container.") - item.parent = parent - if before: - parent.items.insert(i, item) + item.parent = parent + if before: + parent.items.insert(i, item) + else: + parent.items.insert(i + 1, item) else: - parent.items.insert(i + 1, item) - else: - ref.parent.add_item(item, before=before, after=after) + ref.parent.add_item(item, before=before, after=after) + except: + raise ValueError(f"Could not find before or after in container.") self._underlying = self._generate_underlying() return self diff --git a/discord/ui/section.py b/discord/ui/section.py index 0d5157f526..4d88a01ea1 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -116,7 +116,7 @@ def __init__( self.add_item(i) def __len__(self) -> int: - r = sum(len(i) for i in self.items) + r = sum(len(i) for i in self.items) + 1 return (r + 1) if self.accessory else r def _add_component_from_item(self, item: ViewItem): @@ -170,11 +170,11 @@ def add_item( Maximum number of items has been exceeded (3). """ if ( - before - and after - or before + before is not None + and after is not None + or before is not None and (index is not None) - or after + or after is not None and (index is not None) ): raise ValueError("Can only specify one of before, after, and index.") @@ -185,20 +185,18 @@ def add_item( if not isinstance(item, ViewItem): raise TypeError(f"expected ViewItem not {item.__class__!r}") - if before or after: - ref = self.get_item(before or after) - if ref.parent is self: - try: - i = self.items.index(ref) - except: - raise ValueError(f"Could not find before or after in container.") + if before is not None or after is not None: + ref = self.get_item(before or after or 0) + try: + ref = self.get_item(before or after) + i = self.items.index(ref) item.parent = self if before: self.items.insert(i, item) else: self.items.insert(i + 1, item) - else: - ref.parent.add_item(item, before=before, after=after) + except: + raise ValueError(f"Could not find before or after in section.") self._underlying = self._generate_underlying() return self diff --git a/discord/ui/view.py b/discord/ui/view.py index 6729b8f587..6baab2d710 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -959,11 +959,11 @@ def add_item( Maximum number of items has been exceeded (40) """ if ( - before - and after - or before + before is not None + and after is not None + or before is not None and (index is not None) - or after + or after is not None and (index is not None) ): raise ValueError("Can only specify one of before, after, and index.") @@ -985,23 +985,23 @@ def add_item( else: parent = into or self - if before or after: - ref = parent.get_item(before or after) - if ref.parent is parent: - try: + if before is not None or after is not None: + ref = parent.get_item(before or after or 0) + try: + if ref.parent is parent: i = parent.items.index(ref) - except: - raise ValueError(f"Could not find before or after in view.") - item.parent = parent - if before: - parent.items.insert(i, item) - else: - parent.items.insert(i + 1, item) - else: - if isinstance(ref.parent.underlying, ContainerComponent): - ref.parent.add_item(item, before=before, after=after, into=into) + item.parent = parent + if before: + parent.items.insert(i, item) + else: + parent.items.insert(i + 1, item) else: - ref.parent.add_item(item, before=before, after=after) + if isinstance(ref.parent.underlying, ContainerComponent): + ref.parent.add_item(item, before=before, after=after, into=into) + else: + ref.parent.add_item(item, before=before, after=after) + except: + raise ValueError(f"Could not find before or after in view.") return self elif index is not None: From ee76172d817a2cd78e6e9c4d5696fc9b1e30274f Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 06:06:55 -0400 Subject: [PATCH 071/117] fix item resolution --- discord/ui/action_row.py | 4 +++- discord/ui/container.py | 4 +++- discord/ui/section.py | 4 +++- discord/ui/view.py | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 51abd0ea40..148f77c441 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -182,7 +182,9 @@ def add_item( if before is not None or after is not None: try: - ref = self.get_item(before or after or 0) + ref = before or after or 0 + if isinstance(ref, (int, str)): + ref = self.get_item(ref) i = self.children.index(ref) item.parent = self if before: diff --git a/discord/ui/container.py b/discord/ui/container.py index eea23e5740..7d11e33a2e 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -204,7 +204,9 @@ def add_item( parent = into or self if before or after: - ref = parent.get_item(before or after) + ref = before or after or 0 + if isinstance(ref, (int, str)): + ref = parent.get_item(ref) try: if ref.parent is parent: i = parent.items.index(ref) diff --git a/discord/ui/section.py b/discord/ui/section.py index 4d88a01ea1..329d976909 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -186,7 +186,9 @@ def add_item( raise TypeError(f"expected ViewItem not {item.__class__!r}") if before is not None or after is not None: - ref = self.get_item(before or after or 0) + ref = before or after or 0 + if isinstance(ref, (int, str)): + ref = self.get_item(ref) try: ref = self.get_item(before or after) i = self.items.index(ref) diff --git a/discord/ui/view.py b/discord/ui/view.py index 6baab2d710..f955b39545 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -986,7 +986,9 @@ def add_item( parent = into or self if before is not None or after is not None: - ref = parent.get_item(before or after or 0) + ref = before or after or 0 + if isinstance(ref, (int, str)): + ref = parent.get_item(ref) try: if ref.parent is parent: i = parent.items.index(ref) From 411dabe2c36da9819f7b566dff2ec48fb0209287 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 06:18:43 -0400 Subject: [PATCH 072/117] implement DesignerModal.from_dict --- discord/types/components.py | 11 +++++++++++ discord/ui/modal.py | 32 ++++++++++++++++++++++++++++++++ discord/ui/view.py | 4 ++-- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/discord/types/components.py b/discord/types/components.py index af567b6566..9f367c178c 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -46,6 +46,11 @@ class BaseComponent(TypedDict): type: ComponentType id: NotRequired[int] +class Modal(TypedDict): + custom_id: str + title: int + components: list[AllowedModalComponents] + class ActionRow(BaseComponent): type: Literal[1] @@ -254,3 +259,9 @@ class CheckboxComponent(BaseComponent): CheckboxComponent, CheckboxGroupComponent, ] + +AllowedModalComponents = Union[ + ActionRow, + LabelComponent, + TextDisplayComponent, +] \ No newline at end of file diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 7b757d108b..d47340bae0 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -32,6 +32,7 @@ from itertools import groupby from typing import TYPE_CHECKING, Any, Iterator, TypeVar +from ..components import _component_factory from ..enums import ComponentType from ..utils import find from .core import ItemInterface @@ -40,6 +41,7 @@ from .label import Label from .select import Select from .text_display import TextDisplay +from .view import _component_to_item __all__ = ( "BaseModal", @@ -55,6 +57,7 @@ from ..interactions import Interaction from ..state import ConnectionState from ..types.components import Component as ComponentPayload + from ..types.components import Modal as ModalPayload M = TypeVar("M", bound="Modal", covariant=True) @@ -431,6 +434,35 @@ def children(self, value: list[ModalItem]): ) self._children = value + @classmethod + def from_dict( + cls, + data: ModalPayload], + /, + *, + timeout: float | None = 180.0, + ) -> DesignerModal: + """Converts a modal dictionary into a :class:`DesignerModal`. + + Parameters + ---------- + data: ModalPayload + The dict representing a modal + timeout: Optional[:class:`float`] + The timeout of the converted modal. + + Returns + ------- + :class:`DesignerModal` + The converted view. This always returns a :class:`DesignerModal` and not + one of its subclasses. + """ + modal = DesignerModal(timeout=timeout) + components = [_component_factory(d) for d in data.get("components", [])] + for component in components: + modal.add_item(_component_to_item(component)) + return modal + def add_item(self, item: ModalItem) -> Self: """Adds a component to the modal. diff --git a/discord/ui/view.py b/discord/ui/view.py index f955b39545..02d3101ccc 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -882,7 +882,7 @@ def from_message( Returns ------- :class:`View` - The converted view. This always returns a :class:`View` and not + The converted view. This always returns a :class:`DesignerView` and not one of its subclasses. """ view = DesignerView(timeout=timeout) @@ -910,7 +910,7 @@ def from_dict( Returns ------- :class:`DesignerView` - The converted view. This always returns a :class:`View` and not + The converted view. This always returns a :class:`DesignerView` and not one of its subclasses. """ view = DesignerView(timeout=timeout) From 836d3b0238103fe2855deccf77f3dbbe7f4d52b5 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 06:19:14 -0400 Subject: [PATCH 073/117] ] --- discord/ui/modal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index d47340bae0..92738540e2 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -437,7 +437,7 @@ def children(self, value: list[ModalItem]): @classmethod def from_dict( cls, - data: ModalPayload], + data: ModalPayload, /, *, timeout: float | None = 180.0, From 6c6703855598b00644e3e3343365d47ad51bf189 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:19:15 +0000 Subject: [PATCH 074/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/types/components.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/discord/types/components.py b/discord/types/components.py index 9f367c178c..0b663d6f09 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -46,6 +46,7 @@ class BaseComponent(TypedDict): type: ComponentType id: NotRequired[int] + class Modal(TypedDict): custom_id: str title: int @@ -264,4 +265,4 @@ class CheckboxComponent(BaseComponent): ActionRow, LabelComponent, TextDisplayComponent, -] \ No newline at end of file +] From 6bf58d116e4d21ee34fd3a692f72ddb2e4a0359c Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 06:20:13 -0400 Subject: [PATCH 075/117] timeout --- discord/ui/modal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 92738540e2..468b43c4a6 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -440,7 +440,7 @@ def from_dict( data: ModalPayload, /, *, - timeout: float | None = 180.0, + timeout: float | None = None, ) -> DesignerModal: """Converts a modal dictionary into a :class:`DesignerModal`. From 01331641f4d1b41bc14ac16f73231743bb430cb6 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 06:24:25 -0400 Subject: [PATCH 076/117] .get --- discord/ui/modal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 468b43c4a6..f57f8c1e6c 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -457,7 +457,7 @@ def from_dict( The converted view. This always returns a :class:`DesignerModal` and not one of its subclasses. """ - modal = DesignerModal(timeout=timeout) + modal = DesignerModal(title=data.get("title"), custom_id=data.get("custom_id", None), timeout=timeout) components = [_component_factory(d) for d in data.get("components", [])] for component in components: modal.add_item(_component_to_item(component)) From 6b23025bef076e1097f050dc020b8a8a77c3065a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:24:56 +0000 Subject: [PATCH 077/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/modal.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index f57f8c1e6c..4e21196c97 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -457,7 +457,11 @@ def from_dict( The converted view. This always returns a :class:`DesignerModal` and not one of its subclasses. """ - modal = DesignerModal(title=data.get("title"), custom_id=data.get("custom_id", None), timeout=timeout) + modal = DesignerModal( + title=data.get("title"), + custom_id=data.get("custom_id", None), + timeout=timeout, + ) components = [_component_factory(d) for d in data.get("components", [])] for component in components: modal.add_item(_component_to_item(component)) From b83dc4c8509a8f65d0c7da88753c51c247d29b1f Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 06:27:11 -0400 Subject: [PATCH 078/117] get("id") --- discord/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/components.py b/discord/components.py index d6e1eb89a8..aa48da0130 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1347,7 +1347,7 @@ class Label(Component): def __init__(self, data: LabelComponentPayload): self.type: ComponentType = try_enum(ComponentType, data["type"]) - self.id: int = data["id"] + self.id: int = data.get("id") self.component: Component = _component_factory(data["component"]) self.label: str = data["label"] self.description: str | None = data.get("description") From 37ff1d7bb75aa04aaf73fb05f9407c013490403e Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:04:49 -0400 Subject: [PATCH 079/117] complete get_item --- discord/ui/action_row.py | 18 +++++++++++++----- discord/ui/container.py | 29 ++++++++++++++++++----------- discord/ui/core.py | 34 +++++++++++++++++++--------------- discord/ui/section.py | 25 ++++++++++++++++++------- 4 files changed, 68 insertions(+), 38 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 148f77c441..63ff577536 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -33,6 +33,7 @@ from ..enums import ButtonStyle, ChannelType, ComponentType from ..utils import find, get from .button import Button +from .core import _item_getter from .file import File from .item import ItemCallbackType, ViewItem from .select import Select @@ -255,24 +256,31 @@ def replace_item( raise ValueError(f"Could not find original_item in row.") return self - def get_item(self, id: str | int) -> ViewItem | None: + def get_item(self, id: str | int | None = None, **attrs: Any) -> ViewItem | None: """Get an item from this action row. Roughly equivalent to `utils.get(row.children, ...)`. If an ``int`` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. + If ``attrs`` are provided, it will check them by logical AND as done in :func:`~utils.get`. + To have a nested attribute search (i.e. search by ``x.y``) then pass in ``x__y`` as the keyword argument. Parameters ---------- id: Union[:class:`str`, :class:`int`] The id or custom_id of the item to get. + \*\*attrs + Keyword arguments that denote attributes to search with. Returns ------- Optional[:class:`ViewItem`] The item with the matching ``id`` or ``custom_id`` if it exists. """ - if not id: - return None - attr = "id" if isinstance(id, int) else "custom_id" - child = find(lambda i: getattr(i, attr, None) == id, self.children) + child = none + if id: + attr = "id" if isinstance(id, int) else "custom_id" + child = find(lambda i: getattr(i, attr, None) == id, self.children) + elif attrs: + child = _item_getter(self.children, id, **attrs) + return child def add_button( diff --git a/discord/ui/container.py b/discord/ui/container.py index 7d11e33a2e..529ff198c6 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -33,6 +33,7 @@ from ..utils import find, get from .action_row import ActionRow from .button import Button +from .core import _item_getter from .file import File from .item import ItemCallbackType, ViewItem from .media_gallery import MediaGallery @@ -284,30 +285,36 @@ def replace_item( raise ValueError(f"Could not find original_item in container.") return self - def get_item(self, id: str | int) -> ViewItem | None: + def get_item(self, id: str | int | None = None, **attrs: Any) -> ViewItem | None: """Get an item from this container. Roughly equivalent to `utils.get(container.items, ...)`. If an ``int`` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. This method will also search for nested items. + If ``attrs`` are provided, it will check them by logical AND as done in :func:`~utils.get`. + To have a nested attribute search (i.e. search by ``x.y``) then pass in ``x__y`` as the keyword argument. Parameters ---------- - id: Union[:class:`str`, :class:`int`] + id: Optional[Union[:class:`str`, :class:`int`]] The id or custom_id of the item to get. + \*\*attrs + Keyword arguments that denote attributes to search with. Returns ------- Optional[:class:`ViewItem`] The item with the matching ``id`` or ``custom_id`` if it exists. """ - if not id: - return None - attr = "id" if isinstance(id, int) else "custom_id" - child = find(lambda i: getattr(i, attr, None) == id, self.items) - if not child: - for i in self.items: - if hasattr(i, "get_item"): - if child := i.get_item(id): - return child + child = None + if id: + attr = "id" if isinstance(id, int) else "custom_id" + child = find(lambda i: getattr(i, attr, None) == id, self.items) + if not child: + for i in self.items: + if hasattr(i, "get_item"): + if child := i.get_item(id): + return child + elif attrs: + child = _item_getter(self.items, id, **attrs) return child def add_row( diff --git a/discord/ui/core.py b/discord/ui/core.py index a82c2fc6e9..cdb2fd6f22 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -41,6 +41,24 @@ from .view import View +def _item_getter(iterable, id: str | int | None = None, **attrs) -> Item | None: + _all = all + attrget = attrgetter + for i in iterable: + converted = [ + (attrget(attr.replace("__", ".")), value) + for attr, value in attrs.items() + ] + try: + if _all(pred(i) == value for pred, value in converted): + return i + except: + pass + if hasattr(i, "get_item"): + if child := i.get_item(id, **attrs): + return child + return None + class ItemInterface: """The base structure for classes that contain :class:`~discord.ui.Item`. @@ -166,21 +184,7 @@ def get_item(self, custom_id: str | int | None = None, **attrs: Any) -> Item | N if child := i.get_item(custom_id): return child elif attrs: - _all = all - attrget = attrgetter - for i in self.children: - converted = [ - (attrget(attr.replace("__", ".")), value) - for attr, value in attrs.items() - ] - try: - if _all(pred(i) == value for pred, value in converted): - return i - except: - pass - if hasattr(i, "get_item"): - if child := i.get_item(custom_id, **attrs): - return child + child = _item_getter(self.children, custom_id, **attrs) return child diff --git a/discord/ui/section.py b/discord/ui/section.py index 329d976909..aa70a55469 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -32,6 +32,7 @@ from ..enums import ComponentType from ..utils import find, get from .button import Button +from .core import _item_getter from .item import ItemCallbackType, ViewItem from .text_display import TextDisplay from .thumbnail import Thumbnail @@ -274,26 +275,36 @@ def replace_item( raise ValueError(f"Could not find original_item in section.") return self - def get_item(self, id: int | str) -> ViewItem | None: + def get_item(self, id: int | str | None = None, **attrs: Any) -> ViewItem | None: """Get an item from this section. Alias for `utils.get(section.walk_items(), ...)`. If an ``int`` is provided, it will be retrieved by ``id``, otherwise it will check the accessory's ``custom_id``. + If ``attrs`` are provided, it will check them by logical AND as done in :func:`~utils.get`. + To have a nested attribute search (i.e. search by ``x.y``) then pass in ``x__y`` as the keyword argument. Parameters ---------- id: Union[:class:`str`, :class:`int`] The id or custom_id of the item to get. + \*\*attrs + Keyword arguments that denote attributes to search with. Returns ------- Optional[:class:`ViewItem`] The item with the matching ``id`` if it exists. """ - if not id: - return None - attr = "id" if isinstance(id, int) else "custom_id" - if self.accessory and id == getattr(self.accessory, attr, None): - return self.accessory - child = find(lambda i: getattr(i, attr, None) == id, self.items) + child = None + iterr = self.items + if self.accessory: + iterr.append(self.accessory) + if id: + attr = "id" if isinstance(id, int) else "custom_id" + if self.accessory and id == getattr(self.accessory, attr, None): + return self.accessory + child = find(lambda i: getattr(i, attr, None) == id, self.items) + elif attrs: + child = _item_getter(iterr, id, **attrs) + return child def add_text(self, content: str, *, id: int | None = None) -> Self: From 88c9edd8f4f2b4f547c4ff54d7ebe065c787ad04 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:05:18 +0000 Subject: [PATCH 080/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/action_row.py | 2 +- discord/ui/container.py | 2 +- discord/ui/core.py | 4 ++-- discord/ui/section.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 63ff577536..8339540bec 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -257,7 +257,7 @@ def replace_item( return self def get_item(self, id: str | int | None = None, **attrs: Any) -> ViewItem | None: - """Get an item from this action row. Roughly equivalent to `utils.get(row.children, ...)`. + r"""Get an item from this action row. Roughly equivalent to `utils.get(row.children, ...)`. If an ``int`` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. If ``attrs`` are provided, it will check them by logical AND as done in :func:`~utils.get`. To have a nested attribute search (i.e. search by ``x.y``) then pass in ``x__y`` as the keyword argument. diff --git a/discord/ui/container.py b/discord/ui/container.py index 529ff198c6..1b10f211c6 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -286,7 +286,7 @@ def replace_item( return self def get_item(self, id: str | int | None = None, **attrs: Any) -> ViewItem | None: - """Get an item from this container. Roughly equivalent to `utils.get(container.items, ...)`. + r"""Get an item from this container. Roughly equivalent to `utils.get(container.items, ...)`. If an ``int`` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. This method will also search for nested items. If ``attrs`` are provided, it will check them by logical AND as done in :func:`~utils.get`. diff --git a/discord/ui/core.py b/discord/ui/core.py index cdb2fd6f22..4ed4ac3972 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -41,13 +41,13 @@ from .view import View + def _item_getter(iterable, id: str | int | None = None, **attrs) -> Item | None: _all = all attrget = attrgetter for i in iterable: converted = [ - (attrget(attr.replace("__", ".")), value) - for attr, value in attrs.items() + (attrget(attr.replace("__", ".")), value) for attr, value in attrs.items() ] try: if _all(pred(i) == value for pred, value in converted): diff --git a/discord/ui/section.py b/discord/ui/section.py index aa70a55469..3bcbbb326f 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -276,7 +276,7 @@ def replace_item( return self def get_item(self, id: int | str | None = None, **attrs: Any) -> ViewItem | None: - """Get an item from this section. Alias for `utils.get(section.walk_items(), ...)`. + r"""Get an item from this section. Alias for `utils.get(section.walk_items(), ...)`. If an ``int`` is provided, it will be retrieved by ``id``, otherwise it will check the accessory's ``custom_id``. If ``attrs`` are provided, it will check them by logical AND as done in :func:`~utils.get`. To have a nested attribute search (i.e. search by ``x.y``) then pass in ``x__y`` as the keyword argument. From 83d621c7e73c6e6e1ff7c5db550b5e47b6d8f918 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:08:39 -0400 Subject: [PATCH 081/117] console itemgett --- discord/ui/core.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/discord/ui/core.py b/discord/ui/core.py index 4ed4ac3972..5e9fceb1c0 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -41,8 +41,7 @@ from .view import View - -def _item_getter(iterable, id: str | int | None = None, **attrs) -> Item | None: +def _item_getter(iterable, **attrs) -> Item | None: _all = all attrget = attrgetter for i in iterable: @@ -55,7 +54,7 @@ def _item_getter(iterable, id: str | int | None = None, **attrs) -> Item | None: except: pass if hasattr(i, "get_item"): - if child := i.get_item(id, **attrs): + if child := i.get_item(None, **attrs): return child return None From 3a29103bc654be635fd1ad99e262fbdae7c546e3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:09:13 +0000 Subject: [PATCH 082/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/ui/core.py b/discord/ui/core.py index 5e9fceb1c0..b6c237b76e 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -41,6 +41,7 @@ from .view import View + def _item_getter(iterable, **attrs) -> Item | None: _all = all attrget = attrgetter From 5ba58c3fc1802bf99dc8df718facb283e69d0ad2 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:13:22 -0400 Subject: [PATCH 083/117] final --- discord/ui/action_row.py | 4 +++- discord/ui/container.py | 4 +++- discord/ui/core.py | 4 +++- discord/ui/section.py | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 8339540bec..738624be72 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -277,9 +277,11 @@ def get_item(self, id: str | int | None = None, **attrs: Any) -> ViewItem | None child = none if id: attr = "id" if isinstance(id, int) else "custom_id" + if attrs: + attrs[attr] = id child = find(lambda i: getattr(i, attr, None) == id, self.children) elif attrs: - child = _item_getter(self.children, id, **attrs) + child = _item_getter(self.children, **attrs) return child diff --git a/discord/ui/container.py b/discord/ui/container.py index 1b10f211c6..84f2a1168e 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -307,6 +307,8 @@ def get_item(self, id: str | int | None = None, **attrs: Any) -> ViewItem | None child = None if id: attr = "id" if isinstance(id, int) else "custom_id" + if attrs: + attrs[attr] = id child = find(lambda i: getattr(i, attr, None) == id, self.items) if not child: for i in self.items: @@ -314,7 +316,7 @@ def get_item(self, id: str | int | None = None, **attrs: Any) -> ViewItem | None if child := i.get_item(id): return child elif attrs: - child = _item_getter(self.items, id, **attrs) + child = _item_getter(self.items, **attrs) return child def add_row( diff --git a/discord/ui/core.py b/discord/ui/core.py index b6c237b76e..d8b78f9186 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -177,6 +177,8 @@ def get_item(self, custom_id: str | int | None = None, **attrs: Any) -> Item | N child = None if custom_id: attr = "id" if isinstance(custom_id, int) else "custom_id" + if attrs: + attrs[attr] = custom_id child = find(lambda i: getattr(i, attr, None) == custom_id, self.children) if not child: for i in self.children: @@ -184,7 +186,7 @@ def get_item(self, custom_id: str | int | None = None, **attrs: Any) -> Item | N if child := i.get_item(custom_id): return child elif attrs: - child = _item_getter(self.children, custom_id, **attrs) + child = _item_getter(self.children, **attrs) return child diff --git a/discord/ui/section.py b/discord/ui/section.py index 3bcbbb326f..3bb1571ca0 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -299,11 +299,13 @@ def get_item(self, id: int | str | None = None, **attrs: Any) -> ViewItem | None iterr.append(self.accessory) if id: attr = "id" if isinstance(id, int) else "custom_id" + if attrs: + attrs[attr] = id if self.accessory and id == getattr(self.accessory, attr, None): return self.accessory child = find(lambda i: getattr(i, attr, None) == id, self.items) elif attrs: - child = _item_getter(iterr, id, **attrs) + child = _item_getter(iterr, **attrs) return child From c2b02869422865a93ee61b05c94f66d386b5ea0d Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:16:59 -0400 Subject: [PATCH 084/117] i cannot see --- discord/ui/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/core.py b/discord/ui/core.py index d8b78f9186..8551790c54 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -55,7 +55,7 @@ def _item_getter(iterable, **attrs) -> Item | None: except: pass if hasattr(i, "get_item"): - if child := i.get_item(None, **attrs): + if child := i.get_item(**attrs): return child return None From f86e96e74b4a5889e762d8c49cb8d5464a5e63f4 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:18:02 -0400 Subject: [PATCH 085/117] none --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 738624be72..59466fdff1 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -274,7 +274,7 @@ def get_item(self, id: str | int | None = None, **attrs: Any) -> ViewItem | None Optional[:class:`ViewItem`] The item with the matching ``id`` or ``custom_id`` if it exists. """ - child = none + child = None if id: attr = "id" if isinstance(id, int) else "custom_id" if attrs: From 8c8346a53e07867b04782316553c99b48d67cbda Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:28:01 -0400 Subject: [PATCH 086/117] return --- discord/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/state.py b/discord/state.py index 1c39ac8954..f4e5c19660 100644 --- a/discord/state.py +++ b/discord/state.py @@ -419,7 +419,7 @@ def purge_message_view(self, message_id: int) -> None: self._view_store.remove_message_view(message_id) def get_message_view(self, message_id: int) -> BaseView | None: - self._view_store.get_message_view(message_id) + return self._view_store.get_message_view(message_id) def store_modal(self, modal: BaseModal, message_id: int) -> None: self._modal_store.add_modal(modal, message_id) From c81235bc07411d4c5e32388e81905b505aaddb36 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:30:37 -0400 Subject: [PATCH 087/117] any --- discord/ui/action_row.py | 2 +- discord/ui/container.py | 2 +- discord/ui/section.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 59466fdff1..d45cbf860b 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -26,7 +26,7 @@ from collections.abc import Sequence from functools import partial -from typing import TYPE_CHECKING, ClassVar, Iterator, Literal, TypeVar, overload +from typing import TYPE_CHECKING, ClassVar, Iterator, Literal, TypeVar, overload, Any from ..components import ActionRow as ActionRowComponent from ..components import SelectDefaultValue, SelectOption, _component_factory diff --git a/discord/ui/container.py b/discord/ui/container.py index 84f2a1168e..b147fc4fa0 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -24,7 +24,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterator, TypeVar +from typing import TYPE_CHECKING, Iterator, TypeVar, Any from ..colour import Colour from ..components import Container as ContainerComponent diff --git a/discord/ui/section.py b/discord/ui/section.py index 3bb1571ca0..786a2bd899 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -25,7 +25,7 @@ from __future__ import annotations from functools import partial -from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar +from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar, Any from ..components import Section as SectionComponent from ..components import _component_factory From ccbfa905ef5a3ad9b1d79a79e2488fd234441982 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:31:06 +0000 Subject: [PATCH 088/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/action_row.py | 2 +- discord/ui/container.py | 2 +- discord/ui/section.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index d45cbf860b..ce4e1e6e85 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -26,7 +26,7 @@ from collections.abc import Sequence from functools import partial -from typing import TYPE_CHECKING, ClassVar, Iterator, Literal, TypeVar, overload, Any +from typing import TYPE_CHECKING, Any, ClassVar, Iterator, Literal, TypeVar, overload from ..components import ActionRow as ActionRowComponent from ..components import SelectDefaultValue, SelectOption, _component_factory diff --git a/discord/ui/container.py b/discord/ui/container.py index b147fc4fa0..d4e69a4f3a 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -24,7 +24,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterator, TypeVar, Any +from typing import TYPE_CHECKING, Any, Iterator, TypeVar from ..colour import Colour from ..components import Container as ContainerComponent diff --git a/discord/ui/section.py b/discord/ui/section.py index 786a2bd899..8ab5c8d206 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -25,7 +25,7 @@ from __future__ import annotations from functools import partial -from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar, Any +from typing import TYPE_CHECKING, Any, ClassVar, Iterator, TypeVar from ..components import Section as SectionComponent from ..components import _component_factory From b9f0db7473c32ebf85e02fab4f15ab12fb34b2cd Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:49:51 -0400 Subject: [PATCH 089/117] update exceptions --- discord/ui/action_row.py | 6 +++--- discord/ui/container.py | 8 ++++---- discord/ui/section.py | 6 +++--- discord/ui/view.py | 8 ++++---- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index ce4e1e6e85..5ede5cd717 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -193,7 +193,7 @@ def add_item( else: self.children.insert(i + 1, item) except: - raise ValueError(f"Could not find before or after in row.") + raise ValueError(f"Could not find {before or after} in row.") self._underlying = self._generate_underlying() return self @@ -246,14 +246,14 @@ def replace_item( if isinstance(original_item, (str, int)): original_item = self.get_item(original_item) if not original_item: - raise ValueError(f"Could not find original_item in row.") + raise ValueError(f"Could not find {original_item} in row.") try: i = self.children.index(original_item) new_item.parent = self self.children[i] = new_item original_item.parent = None except ValueError: - raise ValueError(f"Could not find original_item in row.") + raise ValueError(f"Could not find {original_item} in row.") return self def get_item(self, id: str | int | None = None, **attrs: Any) -> ViewItem | None: diff --git a/discord/ui/container.py b/discord/ui/container.py index d4e69a4f3a..346b661c3f 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -200,7 +200,7 @@ def add_item( if into and isinstance(into, (str, int)): parent = self.get_item(into) if not parent: - raise ValueError(f"Could not find into in container.") + raise ValueError(f"Could not find {into} in container.") else: parent = into or self @@ -219,7 +219,7 @@ def add_item( else: ref.parent.add_item(item, before=before, after=after) except: - raise ValueError(f"Could not find before or after in container.") + raise ValueError(f"Could not find {before or after} in container.") self._underlying = self._generate_underlying() return self @@ -272,7 +272,7 @@ def replace_item( if isinstance(original_item, (str, int)): original_item = self.get_item(original_item) if not original_item: - raise ValueError(f"Could not find original_item in container.") + raise ValueError(f"Could not find {original_item} in container.") try: if original_item.parent is self: i = self.items.index(original_item) @@ -282,7 +282,7 @@ def replace_item( else: original_item.parent.replace_item(original_item, new_item) except ValueError: - raise ValueError(f"Could not find original_item in container.") + raise ValueError(f"Could not find {original_item} in container.") return self def get_item(self, id: str | int | None = None, **attrs: Any) -> ViewItem | None: diff --git a/discord/ui/section.py b/discord/ui/section.py index 8ab5c8d206..c11433a036 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -199,7 +199,7 @@ def add_item( else: self.items.insert(i + 1, item) except: - raise ValueError(f"Could not find before or after in section.") + raise ValueError(f"Could not find {before or after} in section.") self._underlying = self._generate_underlying() return self @@ -262,7 +262,7 @@ def replace_item( if isinstance(original_item, (str, int)): original_item = self.get_item(original_item) if not original_item: - raise ValueError(f"Could not find original_item in section.") + raise ValueError(f"Could not find {original_item} in section.") try: if original_item is self.accessory: self.accessory = new_item @@ -272,7 +272,7 @@ def replace_item( original_item.parent = None new_item.parent = self except ValueError: - raise ValueError(f"Could not find original_item in section.") + raise ValueError(f"Could not find {original_item} in section.") return self def get_item(self, id: int | str | None = None, **attrs: Any) -> ViewItem | None: diff --git a/discord/ui/view.py b/discord/ui/view.py index 02d3101ccc..2e74bbd484 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -981,7 +981,7 @@ def add_item( if into and isinstance(into, (str, int)): parent = self.get_item(into) if not parent: - raise ValueError(f"Could not find into in view.") + raise ValueError(f"Could not find {into} in view.") else: parent = into or self @@ -1003,7 +1003,7 @@ def add_item( else: ref.parent.add_item(item, before=before, after=after) except: - raise ValueError(f"Could not find before or after in view.") + raise ValueError(f"Could not find {before or after} in view.") return self elif index is not None: @@ -1046,7 +1046,7 @@ def replace_item( if isinstance(original_item, (str, int)): original_item = self.get_item(original_item) if not original_item: - raise ValueError(f"Could not find original_item in view.") + raise ValueError(f"Could not find {original_item} in view.") try: if original_item.parent is self: i = self.children.index(original_item) @@ -1056,7 +1056,7 @@ def replace_item( else: original_item.parent.replace_item(original_item, new_item) except ValueError: - raise ValueError(f"Could not find original_item in view.") + raise ValueError(f"Could not find {original_item} in view.") return self def add_row( From 7a34cead2528f7245b6c0b4aa55d390e0e5c6c61 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:22:52 -0400 Subject: [PATCH 090/117] docs --- discord/ui/container.py | 2 +- discord/ui/view.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 346b661c3f..a0c4580749 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -404,7 +404,7 @@ def add_gallery( return self.add_item(g) def add_file(self, url: str, spoiler: bool = False, id: int | None = None) -> Self: - """Adds a :class:`TextDisplay` to the container. + """Adds a :class:`File` to the container. Parameters ---------- diff --git a/discord/ui/view.py b/discord/ui/view.py index 2e74bbd484..594a2c28f5 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -1095,7 +1095,6 @@ def add_container( ---------- *items: :class:`ViewItem` The items contained in this container. - accessory: Optional[:class:`ViewItem`] id: Optional[:class:`int`] The container's ID. """ @@ -1172,7 +1171,7 @@ def add_gallery( return self.add_item(g) def add_file(self, url: str, spoiler: bool = False, id: int | None = None) -> Self: - """Adds a :class:`TextDisplay` to the view. + """Adds a :class:`File` to the view. Parameters ---------- From 2ffe35f8bf4919e371ee85e7cedd9bad33e75c9f Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:49:33 -0400 Subject: [PATCH 091/117] unfurledmedia convenience --- discord/components.py | 113 ++++++++++++++++++++++++++++++++++++++++++ discord/message.py | 6 ++- 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/discord/components.py b/discord/components.py index aa48da0130..f315037abe 100644 --- a/discord/components.py +++ b/discord/components.py @@ -26,6 +26,8 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, ClassVar, Iterator, TypeVar, overload +import io +from os import PathLike from .asset import AssetMixin from .colour import Colour @@ -38,6 +40,7 @@ SeparatorSpacingSize, try_enum, ) +from .file import File from .flags import AttachmentFlags from .partial_emoji import PartialEmoji, _EmojiTag from .utils import MISSING, find, get_slots @@ -956,6 +959,116 @@ def url(self, value: str) -> None: value if value and value.startswith("attachment://") else None ) + async def save( + self, + fp: io.BufferedIOBase | PathLike, + *, + seek_begin: bool = True, + ) -> int: + """|coro| + + Saves this media into a file-like object. + + Parameters + ---------- + fp: Union[:class:`io.BufferedIOBase`, :class:`os.PathLike`] + The file-like object to save this media 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` + Whether to seek to the beginning of the file after saving is + successfully done. + + Returns + ------- + :class:`int` + The number of bytes written. + + Raises + ------ + HTTPException + Saving the media failed. + NotFound + The media was deleted. + ValueError + You attempted to download from a local ``attachment://`` URL. + """ + data = await self.read(use_cached=use_cached) + + if isinstance(fp, io.BufferedIOBase): + written = fp.write(data) + if seek_begin: + fp.seek(0) + return written + else: + with open(fp, "wb") as f: + return f.write(data) + + async def read(self) -> bytes: + """|coro| + + Retrieves the content of this media as a :class:`bytes` object. + + Returns + ------- + :class:`bytes` + The contents of the media. + + Raises + ------ + HTTPException + Downloading the media failed. + Forbidden + You do not have permissions to access this media. + NotFound + The media was deleted. + ValueError + You attempted to download from a local ``attachment://`` URL. + """ + if self.url.startswith("attachment://"): + raise ValueError("cannot download a local media URL.") + return await self._state.http.get_from_cdn(self.url) + + async def to_file(self, *, filename: str, description: str | None = None, spoiler: bool = False) -> File: + """|coro| + + Converts the media into a :class:`discord.File` suitable for sending via + :meth:`abc.Messageable.send`. + + Parameters + ---------- + filename: :class:`str` + The name to initialize this file with. + description: Optional[:class:`str`] + The description of this file. + spoiler: :class:`bool` + Whether the file is a spoiler. + + Returns + ------- + :class:`File` + The media as a file suitable for sending. + + Raises + ------ + HTTPException + Downloading the media failed. + Forbidden + You do not have permissions to access this media + NotFound + The media was deleted. + ValueError + You attempted to download from a local ``attachment://`` URL. + """ + + data = await self.read() + return File( + io.BytesIO(data), + filename=filename, + spoiler=spoiler, + description=description, + ) + @classmethod def from_dict(cls, data: UnfurledMediaItemPayload, state=None) -> UnfurledMediaItem: r = cls(data.get("url")) diff --git a/discord/message.py b/discord/message.py index 491447340d..8be008b27b 100644 --- a/discord/message.py +++ b/discord/message.py @@ -2345,7 +2345,7 @@ def get_component(self, id: str | int) -> Component | None: return component return None - def get_view(self, cls: BaseView | None = None) -> DesignerView | BaseView | None: + def get_view(self, cls: BaseView | None = None, force: bool = False) -> DesignerView | BaseView | None: """Retrieve this message's view from the ViewStore. If there is no stored view, a new view will be returned if :attr:`components` is not empty. Parameters @@ -2355,13 +2355,15 @@ def get_view(self, cls: BaseView | None = None) -> DesignerView | BaseView | Non By default, this is :class:`discord.ui.DesignerView`. Should a custom class be provided, it must inherit from :class:`discord.ui.BaseView` and properly implement ``from_message``. + force: :class:`bool` + When set to ``True``, this will ignore the ViewStore and forcibly create a new view. Returns ------- Optional[Union[:class:`discord.ui.DesignerView`, :class:`discord.ui.BaseView`]] The view belonging to this message, if it exists or there are components available to create a new view. """ - v = self._state.get_message_view(self.id) + v = self._state.get_message_view(self.id) if not force else None if not v and self.components: if not cls: from .ui.view import DesignerView From 094f6b03c16d3e407e69fcfd7fdf88a62764dba1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 14:50:02 +0000 Subject: [PATCH 092/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/components.py | 6 ++++-- discord/message.py | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/discord/components.py b/discord/components.py index f315037abe..52501386c7 100644 --- a/discord/components.py +++ b/discord/components.py @@ -25,9 +25,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, ClassVar, Iterator, TypeVar, overload import io from os import PathLike +from typing import TYPE_CHECKING, Any, ClassVar, Iterator, TypeVar, overload from .asset import AssetMixin from .colour import Colour @@ -1029,7 +1029,9 @@ async def read(self) -> bytes: raise ValueError("cannot download a local media URL.") return await self._state.http.get_from_cdn(self.url) - async def to_file(self, *, filename: str, description: str | None = None, spoiler: bool = False) -> File: + async def to_file( + self, *, filename: str, description: str | None = None, spoiler: bool = False + ) -> File: """|coro| Converts the media into a :class:`discord.File` suitable for sending via diff --git a/discord/message.py b/discord/message.py index 8be008b27b..0d85d394b8 100644 --- a/discord/message.py +++ b/discord/message.py @@ -2345,7 +2345,9 @@ def get_component(self, id: str | int) -> Component | None: return component return None - def get_view(self, cls: BaseView | None = None, force: bool = False) -> DesignerView | BaseView | None: + def get_view( + self, cls: BaseView | None = None, force: bool = False + ) -> DesignerView | BaseView | None: """Retrieve this message's view from the ViewStore. If there is no stored view, a new view will be returned if :attr:`components` is not empty. Parameters From 377e5363966f94988f3ab23e55a7ab705c0e45c6 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:11:36 -0400 Subject: [PATCH 093/117] resolved name --- discord/components.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/discord/components.py b/discord/components.py index 52501386c7..b363c1f0f2 100644 --- a/discord/components.py +++ b/discord/components.py @@ -26,8 +26,9 @@ from __future__ import annotations import io -from os import PathLike +from os import path, PathLike from typing import TYPE_CHECKING, Any, ClassVar, Iterator, TypeVar, overload +from urllib.parse import urlparse from .asset import AssetMixin from .colour import Colour @@ -959,6 +960,15 @@ def url(self, value: str) -> None: value if value and value.startswith("attachment://") else None ) + @property + def resolved_name(self) -> str | None: + """Attempts to return the filename within this URL.""" + try: + parsed = urlparse(self.url) + return path.basename(parsed.path) + except: + return None + async def save( self, fp: io.BufferedIOBase | PathLike, @@ -1030,7 +1040,7 @@ async def read(self) -> bytes: return await self._state.http.get_from_cdn(self.url) async def to_file( - self, *, filename: str, description: str | None = None, spoiler: bool = False + self, filename: str, *, description: str | None = None, spoiler: bool = False ) -> File: """|coro| @@ -1165,6 +1175,11 @@ def __init__(self, url, *, description=None, spoiler=False): self.description: str | None = description self.spoiler: bool = spoiler + def __repr__(self) -> str: + return ( + f"<MediaGalleryItem url={self.url!r} spoiler={self.spoiler!r}>" + ) + @property def url(self) -> str: return self.media.url From 98917bd245f7cab293242539a3dea04ec616c733 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:13:16 -0400 Subject: [PATCH 094/117] docs --- discord/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/components.py b/discord/components.py index b363c1f0f2..e65008d418 100644 --- a/discord/components.py +++ b/discord/components.py @@ -962,7 +962,7 @@ def url(self, value: str) -> None: @property def resolved_name(self) -> str | None: - """Attempts to return the filename within this URL.""" + """Attempts to return the filename within this media's URL, if present.""" try: parsed = urlparse(self.url) return path.basename(parsed.path) From 75f7a39d71d3be77b16d09e8427d5d89315edd42 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 15:15:31 +0000 Subject: [PATCH 095/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/components.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/discord/components.py b/discord/components.py index e65008d418..021af05800 100644 --- a/discord/components.py +++ b/discord/components.py @@ -26,7 +26,7 @@ from __future__ import annotations import io -from os import path, PathLike +from os import PathLike, path from typing import TYPE_CHECKING, Any, ClassVar, Iterator, TypeVar, overload from urllib.parse import urlparse @@ -1176,9 +1176,7 @@ def __init__(self, url, *, description=None, spoiler=False): self.spoiler: bool = spoiler def __repr__(self) -> str: - return ( - f"<MediaGalleryItem url={self.url!r} spoiler={self.spoiler!r}>" - ) + return f"<MediaGalleryItem url={self.url!r} spoiler={self.spoiler!r}>" @property def url(self) -> str: From 44e9611b18365aa8910367a101d4ff16099434c7 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:16:24 -0400 Subject: [PATCH 096/117] state gate --- discord/components.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discord/components.py b/discord/components.py index 021af05800..204fc415ac 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1037,6 +1037,8 @@ async def read(self) -> bytes: """ if self.url.startswith("attachment://"): raise ValueError("cannot download a local media URL.") + if not self._state: + raise RuntimeError("can only read when media is received from Discord.") return await self._state.http.get_from_cdn(self.url) async def to_file( From 29573a60bb7dfe6a77fa8edfb989db06aa8f6731 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:18:58 -0400 Subject: [PATCH 097/117] default filename --- discord/components.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/discord/components.py b/discord/components.py index 204fc415ac..cb4b771daa 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1042,7 +1042,7 @@ async def read(self) -> bytes: return await self._state.http.get_from_cdn(self.url) async def to_file( - self, filename: str, *, description: str | None = None, spoiler: bool = False + self, filename: str | None = None, *, description: str | None = None, spoiler: bool = False ) -> File: """|coro| @@ -1052,7 +1052,7 @@ async def to_file( Parameters ---------- filename: :class:`str` - The name to initialize this file with. + The name to initialize this file with. Defaults to the resolved name if available. description: Optional[:class:`str`] The description of this file. spoiler: :class:`bool` @@ -1074,11 +1074,14 @@ async def to_file( ValueError You attempted to download from a local ``attachment://`` URL. """ + name = filename or self.resolved_name + if not name: + raise ValueError("no resolved_name available, please provide filename manually.") data = await self.read() return File( io.BytesIO(data), - filename=filename, + filename=name, spoiler=spoiler, description=description, ) From c16fd6f91e52af7a35d757826dc76ae7ee8055f4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 15:19:25 +0000 Subject: [PATCH 098/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/components.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/discord/components.py b/discord/components.py index cb4b771daa..29af32ed4e 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1042,7 +1042,11 @@ async def read(self) -> bytes: return await self._state.http.get_from_cdn(self.url) async def to_file( - self, filename: str | None = None, *, description: str | None = None, spoiler: bool = False + self, + filename: str | None = None, + *, + description: str | None = None, + spoiler: bool = False, ) -> File: """|coro| @@ -1076,7 +1080,9 @@ async def to_file( """ name = filename or self.resolved_name if not name: - raise ValueError("no resolved_name available, please provide filename manually.") + raise ValueError( + "no resolved_name available, please provide filename manually." + ) data = await self.read() return File( From 2542ad555d2c137f43dbd0f0a6a05d7d8060935b Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:39:15 -0400 Subject: [PATCH 099/117] remove_item --- discord/components.py | 2 +- discord/ui/action_row.py | 2 ++ discord/ui/container.py | 2 ++ discord/ui/section.py | 2 ++ discord/ui/view.py | 2 ++ 5 files changed, 9 insertions(+), 1 deletion(-) diff --git a/discord/components.py b/discord/components.py index 29af32ed4e..48c6f232b8 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1003,7 +1003,7 @@ async def save( ValueError You attempted to download from a local ``attachment://`` URL. """ - data = await self.read(use_cached=use_cached) + data = await self.read() if isinstance(fp, io.BufferedIOBase): written = fp.write(data) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 5ede5cd717..6d42524e52 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -219,6 +219,8 @@ def remove_item(self, item: ViewItem | str | int) -> Self: if isinstance(item, (str, int)): item = self.get_item(item) + if not item: + return self try: self.children.remove(item) item.parent = None diff --git a/discord/ui/container.py b/discord/ui/container.py index a0c4580749..3e89ebbcd1 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -245,6 +245,8 @@ def remove_item(self, item: ViewItem | str | int) -> Self: if isinstance(item, (str, int)): item = self.get_item(item) + if not item: + return self try: if item.parent is self: self.items.remove(item) diff --git a/discord/ui/section.py b/discord/ui/section.py index c11433a036..e33fe4cd9c 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -226,6 +226,8 @@ def remove_item(self, item: ViewItem | str | int) -> Self: if isinstance(item, (str, int)): item = self.get_item(item) + if not item: + return self try: if item is self.accessory: self.accessory = None diff --git a/discord/ui/view.py b/discord/ui/view.py index 594a2c28f5..5ae3b4b89f 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -284,6 +284,8 @@ def remove_item(self, item: ViewItem[V] | int | str) -> Self: if isinstance(item, (str, int)): item = self.get_item(item) + if not item: + return self try: if item.parent is self: self.children.remove(item) From 5dd5ec3ecd609231f021c6ce1540428486b20a5a Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:54:37 -0400 Subject: [PATCH 100/117] avoid tragedy oh god --- discord/ui/section.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index e33fe4cd9c..507338dee9 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -296,7 +296,7 @@ def get_item(self, id: int | str | None = None, **attrs: Any) -> ViewItem | None The item with the matching ``id`` if it exists. """ child = None - iterr = self.items + iterr = self.items[:] if self.accessory: iterr.append(self.accessory) if id: From 27a15b5b0905a133ea2d1a8f30bb04cc2d5f3015 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 16:38:00 +0000 Subject: [PATCH 101/117] style(pre-commit): auto fixes from pre-commit.com hooks --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b56feebe..2850890414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ These changes are available on the `master` branch, but have not yet been releas ### Removed ## [2.8.0rc1] - 2026-03-21 + ### Added - Added support for community invites. @@ -91,6 +92,7 @@ These changes are available on the `master` branch, but have not yet been releas restrictions. ([#3056](https://github.com/Pycord-Development/pycord/pull/3056)) - Removed the following methods: `Guild.set_mfa_required`, `Guild.delete`, `Template.create_guild`, and `Client.create_guild`. + ## [2.7.1] - 2026-02-09 ### Added From d84a11746acd6cd628e110c0f0c8a9c3b3293617 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:07:30 +0000 Subject: [PATCH 102/117] simplify param resolution --- discord/ui/action_row.py | 9 +-------- discord/ui/container.py | 9 +-------- discord/ui/section.py | 9 +-------- discord/ui/view.py | 9 +-------- 4 files changed, 4 insertions(+), 32 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 6d42524e52..4bc9fccab9 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -164,14 +164,7 @@ def add_item( TypeError A :class:`ViewItem` was not passed. """ - if ( - before is not None - and after is not None - or before is not None - and (index is not None) - or after is not None - and (index is not None) - ): + if sum(x is not None for x in (before, after, index)) > 1: raise ValueError("Can only specify one of before, after, and index.") if not isinstance(item, (Select, Button)): diff --git a/discord/ui/container.py b/discord/ui/container.py index 3e89ebbcd1..3d0500e5a5 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -180,14 +180,7 @@ def add_item( TypeError A :class:`ViewItem` was not passed. """ - if ( - before is not None - and after is not None - or before is not None - and (index is not None) - or after is not None - and (index is not None) - ): + if sum(x is not None for x in (before, after, index)) > 1: raise ValueError("Can only specify one of before, after, and index.") if not isinstance(item, ViewItem): diff --git a/discord/ui/section.py b/discord/ui/section.py index 507338dee9..755c9a0d83 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -170,14 +170,7 @@ def add_item( ValueError Maximum number of items has been exceeded (3). """ - if ( - before is not None - and after is not None - or before is not None - and (index is not None) - or after is not None - and (index is not None) - ): + if sum(x is not None for x in (before, after, index)) > 1: raise ValueError("Can only specify one of before, after, and index.") if len(self.items) >= 3: diff --git a/discord/ui/view.py b/discord/ui/view.py index 5ae3b4b89f..755a52f37b 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -960,14 +960,7 @@ def add_item( ValueError Maximum number of items has been exceeded (40) """ - if ( - before is not None - and after is not None - or before is not None - and (index is not None) - or after is not None - and (index is not None) - ): + if sum(x is not None for x in (before, after, index)) > 1: raise ValueError("Can only specify one of before, after, and index.") if len(self) >= self.MAX_ITEMS: From 521e60e652d16157cd3bed8b7ab70f08dd867755 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:17:38 +0000 Subject: [PATCH 103/117] clarify len --- discord/ui/action_row.py | 5 +++++ discord/ui/container.py | 5 +++++ discord/ui/core.py | 7 +++++++ discord/ui/item.py | 5 +++++ discord/ui/section.py | 5 +++++ 5 files changed, 27 insertions(+) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 4bc9fccab9..7c0f4a664d 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -63,6 +63,11 @@ class ActionRow(ViewItem[V]): .. versionadded:: 2.7 + .. describe:: len(x) + + Returns the total count of all items in this row. + This includes the row itself, counting towards Discord's component limits. + Parameters ---------- *items: :class:`ViewItem` diff --git a/discord/ui/container.py b/discord/ui/container.py index 3d0500e5a5..aedcf0ae32 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -69,6 +69,11 @@ class Container(ViewItem[V]): .. versionadded:: 2.7 + .. describe:: len(x) + + Returns the total count of all items in this container. + This includes the container itself, counting towards Discord's component limits. + Parameters ---------- *items: :class:`ViewItem` diff --git a/discord/ui/core.py b/discord/ui/core.py index 8551790c54..044fe0bb95 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -65,6 +65,13 @@ class ItemInterface: .. versionadded:: 2.7 + .. container:: operations + + .. describe:: len(x) + + Returns the total count of all items in this interface. + This includes items that contain other items, which count towards Discord's component limits. + Parameters ---------- *items: :class:`Item` diff --git a/discord/ui/item.py b/discord/ui/item.py index 6825df2233..fbbcb6ce01 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -58,6 +58,11 @@ class Item(Generic[T]): .. versionchanged:: 2.7 Now used as base class for :class:`ViewItem` and :class:`ModalItem`. + + .. describe:: len(x) + + Returns how much this item counts towards Discord's component limits. + This is 1 for all items, plus 1 for each child item. """ __item_repr_attributes__: tuple[str, ...] = ("id",) diff --git a/discord/ui/section.py b/discord/ui/section.py index 755c9a0d83..29af33fcea 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -55,6 +55,11 @@ class Section(ViewItem[V]): .. versionadded:: 2.7 + .. describe:: len(x) + + Returns the total count of all items in this section. + This includes the section itself, counting towards Discord's component limits. + Parameters ---------- *items: :class:`ViewItem` From 6c3f8dfb08672909d470259a6d261cec6d3929f1 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:18:11 +0000 Subject: [PATCH 104/117] Update discord/ui/modal.py Co-authored-by: Paillat <jeremiecotti@ik.me> Signed-off-by: Nelo <41271523+NeloBlivion@users.noreply.github.com> --- discord/ui/modal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 4e21196c97..00a0e13e23 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -519,7 +519,7 @@ def add_text(self, content: str, id: int | None = None) -> Self: ---------- content: :class:`str` The content of the TextDisplay - id: Optiona[:class:`int`] + id: Optional[:class:`int`] The text display's ID. """ From cae8c7a1b7977b39c2de60636537097cabdb42df Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:19:33 +0000 Subject: [PATCH 105/117] Update discord/ui/media_gallery.py Co-authored-by: Paillat <jeremiecotti@ik.me> Signed-off-by: Nelo <41271523+NeloBlivion@users.noreply.github.com> --- discord/ui/media_gallery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index dfe54e10d8..20f376cfa8 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -168,7 +168,7 @@ def replace_item(self, index: int, new_item: MediaGalleryItem) -> Self: Parameters ---------- - original_item: :class:`int` + index: :class:`int` The index of the item to replace in this gallery. new_item: :class:`MediaGalleryItem` The new item to insert into the gallery. From 11635d815a5925b1ea802e9963d39a0b4ea62200 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:23:05 +0000 Subject: [PATCH 106/117] Update discord/types/components.py Co-authored-by: Paillat <jeremiecotti@ik.me> Signed-off-by: Nelo <41271523+NeloBlivion@users.noreply.github.com> --- discord/types/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/types/components.py b/discord/types/components.py index 0b663d6f09..37f2c7ab21 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -49,7 +49,7 @@ class BaseComponent(TypedDict): class Modal(TypedDict): custom_id: str - title: int + title: str components: list[AllowedModalComponents] From d36f6e2f098639dd85949b15c6216acb42ffb230 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:31:39 +0000 Subject: [PATCH 107/117] extend cl --- CHANGELOG.md | 7 ++++++- discord/ui/action_row.py | 8 +++++--- discord/ui/container.py | 8 +++++--- discord/ui/core.py | 6 +++--- discord/ui/item.py | 8 +++++--- discord/ui/section.py | 8 +++++--- 6 files changed, 29 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c005b7b2f5..8416a3aeb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,12 @@ These changes are available on the `master` branch, but have not yet been releas ### Added - Added `replace_item` to `DesignerView`, `Section`, `Container`, `ActionRow`, & - `MediaGallery` ([#3032](https://github.com/Pycord-Development/pycord/pull/3032)) + `MediaGallery` ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) +- Added `index`, `before`, and `after` to `add_item` functions ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) +- Added arbitrary kwarg support to `get_item` functions ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) +- Added `Message.get_view` ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) +- Added `from_dict`, `add_label`, & `add_text` to `DesignerModal` ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) +- Added `read`, `save`, and `to_file` to `UnfurledMediaItem` ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) ### Changed diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 7c0f4a664d..55704b2f1b 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -63,10 +63,12 @@ class ActionRow(ViewItem[V]): .. versionadded:: 2.7 - .. describe:: len(x) + .. container:: operations - Returns the total count of all items in this row. - This includes the row itself, counting towards Discord's component limits. + .. describe:: len(x) + + Returns the total count of all items in this row. + This includes the row itself, counting towards Discord's component limits. Parameters ---------- diff --git a/discord/ui/container.py b/discord/ui/container.py index aedcf0ae32..104f43fcd0 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -69,10 +69,12 @@ class Container(ViewItem[V]): .. versionadded:: 2.7 - .. describe:: len(x) + .. container:: operations - Returns the total count of all items in this container. - This includes the container itself, counting towards Discord's component limits. + .. describe:: len(x) + + Returns the total count of all items in this container. + This includes the container itself, counting towards Discord's component limits. Parameters ---------- diff --git a/discord/ui/core.py b/discord/ui/core.py index 044fe0bb95..d1da63093c 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -67,10 +67,10 @@ class ItemInterface: .. container:: operations - .. describe:: len(x) + .. describe:: len(x) - Returns the total count of all items in this interface. - This includes items that contain other items, which count towards Discord's component limits. + Returns the total count of all items in this interface. + This includes items that contain other items, which count towards Discord's component limits. Parameters ---------- diff --git a/discord/ui/item.py b/discord/ui/item.py index fbbcb6ce01..8da2e6ff02 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -59,10 +59,12 @@ class Item(Generic[T]): .. versionchanged:: 2.7 Now used as base class for :class:`ViewItem` and :class:`ModalItem`. - .. describe:: len(x) + .. container:: operations - Returns how much this item counts towards Discord's component limits. - This is 1 for all items, plus 1 for each child item. + .. describe:: len(x) + + Returns how much this item counts towards Discord's component limits. + This is 1 for all items, plus 1 for each child item. """ __item_repr_attributes__: tuple[str, ...] = ("id",) diff --git a/discord/ui/section.py b/discord/ui/section.py index 29af33fcea..e3f068da15 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -55,10 +55,12 @@ class Section(ViewItem[V]): .. versionadded:: 2.7 - .. describe:: len(x) + .. container:: operations - Returns the total count of all items in this section. - This includes the section itself, counting towards Discord's component limits. + .. describe:: len(x) + + Returns the total count of all items in this section. + This includes the section itself, counting towards Discord's component limits. Parameters ---------- From aa3be7d11679ddc5b20e63193d63ea5dcac62737 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:32:07 +0000 Subject: [PATCH 108/117] style(pre-commit): auto fixes from pre-commit.com hooks --- CHANGELOG.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8416a3aeb1..c2d6df42e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,16 @@ These changes are available on the `master` branch, but have not yet been releas - Added `replace_item` to `DesignerView`, `Section`, `Container`, `ActionRow`, & `MediaGallery` ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) -- Added `index`, `before`, and `after` to `add_item` functions ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) -- Added arbitrary kwarg support to `get_item` functions ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) -- Added `Message.get_view` ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) -- Added `from_dict`, `add_label`, & `add_text` to `DesignerModal` ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) -- Added `read`, `save`, and `to_file` to `UnfurledMediaItem` ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) +- Added `index`, `before`, and `after` to `add_item` functions + ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) +- Added arbitrary kwarg support to `get_item` functions + ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) +- Added `Message.get_view` + ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) +- Added `from_dict`, `add_label`, & `add_text` to `DesignerModal` + ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) +- Added `read`, `save`, and `to_file` to `UnfurledMediaItem` + ([#3093](https://github.com/Pycord-Development/pycord/pull/3093)) ### Changed From 04bd81d632cd4474ab9f13349eca203cd84edef2 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:40:17 +0000 Subject: [PATCH 109/117] improve resolved_name --- discord/components.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/discord/components.py b/discord/components.py index 48c6f232b8..4f9184af5b 100644 --- a/discord/components.py +++ b/discord/components.py @@ -963,11 +963,11 @@ def url(self, value: str) -> None: @property def resolved_name(self) -> str | None: """Attempts to return the filename within this media's URL, if present.""" - try: + if self.url.startswith("attachment://") + return self.url.replace("attachment://", "") + else: parsed = urlparse(self.url) return path.basename(parsed.path) - except: - return None async def save( self, @@ -1056,7 +1056,7 @@ async def to_file( Parameters ---------- filename: :class:`str` - The name to initialize this file with. Defaults to the resolved name if available. + The name to initialize this file with. Defaults to :attr:`resolved_name` if available. description: Optional[:class:`str`] The description of this file. spoiler: :class:`bool` From c4a127bd9bebe19f87d01791e78c2fa8a8c76a50 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:58:09 +0000 Subject: [PATCH 110/117] improve exceptions --- discord/components.py | 2 +- discord/ui/action_row.py | 5 ++++- discord/ui/container.py | 4 +++- discord/ui/section.py | 6 +++--- discord/ui/view.py | 5 +++-- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/discord/components.py b/discord/components.py index 4f9184af5b..05afcb10e8 100644 --- a/discord/components.py +++ b/discord/components.py @@ -963,7 +963,7 @@ def url(self, value: str) -> None: @property def resolved_name(self) -> str | None: """Attempts to return the filename within this media's URL, if present.""" - if self.url.startswith("attachment://") + if self.url.startswith("attachment://"): return self.url.replace("attachment://", "") else: parsed = urlparse(self.url) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 55704b2f1b..79e7d7dbdc 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -170,6 +170,9 @@ def add_item( ------ TypeError A :class:`ViewItem` was not passed. + ValueError + Maximum number of items has been exceeded (5 buttons or 1 select), + or a searched item could not be found in the row. """ if sum(x is not None for x in (before, after, index)) > 1: raise ValueError("Can only specify one of before, after, and index.") @@ -192,7 +195,7 @@ def add_item( self.children.insert(i, item) else: self.children.insert(i + 1, item) - except: + except ValueError: raise ValueError(f"Could not find {before or after} in row.") self._underlying = self._generate_underlying() return self diff --git a/discord/ui/container.py b/discord/ui/container.py index 104f43fcd0..43c03456d2 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -186,6 +186,8 @@ def add_item( ------ TypeError A :class:`ViewItem` was not passed. + ValueError + A searched item could not be found in the container. """ if sum(x is not None for x in (before, after, index)) > 1: raise ValueError("Can only specify one of before, after, and index.") @@ -218,7 +220,7 @@ def add_item( parent.items.insert(i + 1, item) else: ref.parent.add_item(item, before=before, after=after) - except: + except (ValueError, AttributeError): raise ValueError(f"Could not find {before or after} in container.") self._underlying = self._generate_underlying() return self diff --git a/discord/ui/section.py b/discord/ui/section.py index e3f068da15..7a9aaee8a5 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -175,7 +175,8 @@ def add_item( TypeError An :class:`ViewItem` was not passed. ValueError - Maximum number of items has been exceeded (3). + Maximum number of items has been exceeded (3), + or a searched item could not be found in the section. """ if sum(x is not None for x in (before, after, index)) > 1: raise ValueError("Can only specify one of before, after, and index.") @@ -191,14 +192,13 @@ def add_item( if isinstance(ref, (int, str)): ref = self.get_item(ref) try: - ref = self.get_item(before or after) i = self.items.index(ref) item.parent = self if before: self.items.insert(i, item) else: self.items.insert(i + 1, item) - except: + except ValueError: raise ValueError(f"Could not find {before or after} in section.") self._underlying = self._generate_underlying() return self diff --git a/discord/ui/view.py b/discord/ui/view.py index 755a52f37b..516ef6dffb 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -958,7 +958,8 @@ def add_item( TypeError A :class:`ViewItem` was not passed. ValueError - Maximum number of items has been exceeded (40) + Maximum number of items has been exceeded (40), + or a searched item could not be found in the view. """ if sum(x is not None for x in (before, after, index)) > 1: raise ValueError("Can only specify one of before, after, and index.") @@ -997,7 +998,7 @@ def add_item( ref.parent.add_item(item, before=before, after=after, into=into) else: ref.parent.add_item(item, before=before, after=after) - except: + except (ValueError, AttributeError): raise ValueError(f"Could not find {before or after} in view.") return self From ec5b6450bfd6586634f5e56dad30c2cf0200006e Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:04:38 +0000 Subject: [PATCH 111/117] AttributeError --- discord/ui/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/core.py b/discord/ui/core.py index d1da63093c..bd75571b7e 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -52,7 +52,7 @@ def _item_getter(iterable, **attrs) -> Item | None: try: if _all(pred(i) == value for pred, value in converted): return i - except: + except AttributeError: pass if hasattr(i, "get_item"): if child := i.get_item(**attrs): From 9a91538014bbd9d9a1632281a253bbf6f9936959 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:14:11 +0000 Subject: [PATCH 112/117] fix view check --- discord/ui/media_gallery.py | 2 +- discord/ui/section.py | 2 +- discord/ui/view.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 20f376cfa8..0d6e308fed 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -142,7 +142,7 @@ def add_item( """ if len(self.items) >= 10: - raise ValueError("maximum number of items exceeded") + raise ValueError("maximum number of items exceeded (10)") item = MediaGalleryItem(url, description=description, spoiler=spoiler) diff --git a/discord/ui/section.py b/discord/ui/section.py index 7a9aaee8a5..5b311635e7 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -182,7 +182,7 @@ def add_item( raise ValueError("Can only specify one of before, after, and index.") if len(self.items) >= 3: - raise ValueError("maximum number of children exceeded") + raise ValueError("maximum number of children exceeded (3)") if not isinstance(item, ViewItem): raise TypeError(f"expected ViewItem not {item.__class__!r}") diff --git a/discord/ui/view.py b/discord/ui/view.py index 516ef6dffb..cec6b6fc57 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -964,8 +964,8 @@ def add_item( if sum(x is not None for x in (before, after, index)) > 1: raise ValueError("Can only specify one of before, after, and index.") - if len(self) >= self.MAX_ITEMS: - raise ValueError("maximum number of children exceeded") + if len(self) + len(item) > self.MAX_ITEMS: + raise ValueError("maximum number of children exceeded (40)") if not isinstance(item, ViewItem): raise TypeError(f"expected item to be ViewItem, not {item.__class__!r}") From cf57dc98c922d1f2c7c1d736cd5eec9e433db99c Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:00:01 +0000 Subject: [PATCH 113/117] final mediagallery convenience --- discord/asset.py | 7 ++++++ discord/ui/media_gallery.py | 49 ++++++++++++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/discord/asset.py b/discord/asset.py index 659d6dd5eb..36bdf55988 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -27,6 +27,7 @@ import io import os +from urllib.parse import urlparse from typing import TYPE_CHECKING, Any, Literal import yarl @@ -122,6 +123,12 @@ async def save( with open(fp, "wb") as f: return f.write(data) + @property + def cdn_name(self) -> str | None: + """Attempts to return the filename within this asset's URL, if present.""" + parsed = urlparse(self.url) + return os.path.basename(parsed.path) + class Asset(AssetMixin): """Represents a CDN asset on Discord. diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 0d6e308fed..9208c40a3d 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -28,6 +28,7 @@ from ..components import MediaGallery as MediaGalleryComponent from ..components import MediaGalleryItem +from ..asset import AssetMixin from ..enums import ComponentType from .item import ViewItem @@ -37,6 +38,8 @@ from typing_extensions import Self from ..types.components import MediaGalleryComponent as MediaGalleryComponentPayload + from ..file import File + from ..message import Attachment from .view import DesignerView @@ -109,7 +112,7 @@ def append_item(self, item: MediaGalleryItem) -> Self: """ if len(self.items) >= 10: - raise ValueError("maximum number of items exceeded") + raise ValueError("maximum number of items exceeded (10)") if not isinstance(item, MediaGalleryItem): raise TypeError(f"expected MediaGalleryItem not {item.__class__!r}") @@ -141,9 +144,6 @@ def add_item( Maximum number of items has been exceeded (10). """ - if len(self.items) >= 10: - raise ValueError("maximum number of items exceeded (10)") - item = MediaGalleryItem(url, description=description, spoiler=spoiler) return self.append_item(item) @@ -183,6 +183,47 @@ def to_component_dict(self) -> MediaGalleryComponentPayload: self._underlying = self._generate_underlying() return super().to_component_dict() + @classmethod + def from_assets(cls: type[M], *assets: Attachment | AssetMixin, id: int | None = None, new: bool = False) -> M: + """Converts a list of :class:`discord.Attachment` or :class:`discord.Asset` to a gallery. + + Parameters + ---------- + \*assets: Union[:class:`discord.Attachment`, :class:`discord.AssetMixin`] + An argument list of assets to add to the gallery. These can be attachments, emojis, stickers, avatars, or anything other model served by Discord. + id: Optional[:class:`int`] + The gallery's ID. + new: :class:`bool` + If ``True``, uses local ``attachment://`` URLs instead of the CDN URLs. Ideal when reuploading assets. + """ + gallery = cls(id=id) + for a in attachments: + name = a.cdn_name if isinstance(a, AssetMixin) else a.filename + url = f"attachment://{name}" if new else a.url + gallery.add_item(url=url, description=getattr(a, "description", None), spoiler=getattr(a, "spoiler", False)) + return gallery + + @classmethod + def from_files(cls: type[M], *files: File, id: int | None = None) -> M: + """Converts a list of local :class:`discord.File` to a gallery. + + Parameters + ---------- + \*files: :class:`discord.File` + An argument list of :class:`discord.File` to add to the gallery. + id: Optional[:class:`int`] + The gallery's ID. + """ + gallery = cls(id=id) + for f in files: + gallery.add_item(url=f"attachment://{f.filename}", description=a.description, spoiler=a.spoiler) + return gallery + + async def to_files(self) -> list[File]: + """Converts this gallery to a list of :class:`discord.File` for use with :func:`discord.Messageable.send`. + """ + return [await f.media.to_file() for f in self.items] + @classmethod def from_component(cls: type[M], component: MediaGalleryComponent) -> M: return cls(*component.items, id=component.id) From 21d6748989ef07b9ff151468bd934d29727ea997 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:00:31 +0000 Subject: [PATCH 114/117] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/asset.py | 2 +- discord/ui/media_gallery.py | 30 +++++++++++++++++++++--------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/discord/asset.py b/discord/asset.py index 36bdf55988..1c5c82ca9f 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -27,8 +27,8 @@ import io import os -from urllib.parse import urlparse from typing import TYPE_CHECKING, Any, Literal +from urllib.parse import urlparse import yarl diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 9208c40a3d..489e3a3ee9 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -26,9 +26,9 @@ from typing import TYPE_CHECKING, TypeVar +from ..asset import AssetMixin from ..components import MediaGallery as MediaGalleryComponent from ..components import MediaGalleryItem -from ..asset import AssetMixin from ..enums import ComponentType from .item import ViewItem @@ -37,9 +37,9 @@ if TYPE_CHECKING: from typing_extensions import Self - from ..types.components import MediaGalleryComponent as MediaGalleryComponentPayload from ..file import File from ..message import Attachment + from ..types.components import MediaGalleryComponent as MediaGalleryComponentPayload from .view import DesignerView @@ -184,8 +184,13 @@ def to_component_dict(self) -> MediaGalleryComponentPayload: return super().to_component_dict() @classmethod - def from_assets(cls: type[M], *assets: Attachment | AssetMixin, id: int | None = None, new: bool = False) -> M: - """Converts a list of :class:`discord.Attachment` or :class:`discord.Asset` to a gallery. + def from_assets( + cls: type[M], + *assets: Attachment | AssetMixin, + id: int | None = None, + new: bool = False, + ) -> M: + r"""Converts a list of :class:`discord.Attachment` or :class:`discord.Asset` to a gallery. Parameters ---------- @@ -200,12 +205,16 @@ def from_assets(cls: type[M], *assets: Attachment | AssetMixin, id: int | None = for a in attachments: name = a.cdn_name if isinstance(a, AssetMixin) else a.filename url = f"attachment://{name}" if new else a.url - gallery.add_item(url=url, description=getattr(a, "description", None), spoiler=getattr(a, "spoiler", False)) + gallery.add_item( + url=url, + description=getattr(a, "description", None), + spoiler=getattr(a, "spoiler", False), + ) return gallery @classmethod def from_files(cls: type[M], *files: File, id: int | None = None) -> M: - """Converts a list of local :class:`discord.File` to a gallery. + r"""Converts a list of local :class:`discord.File` to a gallery. Parameters ---------- @@ -216,12 +225,15 @@ def from_files(cls: type[M], *files: File, id: int | None = None) -> M: """ gallery = cls(id=id) for f in files: - gallery.add_item(url=f"attachment://{f.filename}", description=a.description, spoiler=a.spoiler) + gallery.add_item( + url=f"attachment://{f.filename}", + description=a.description, + spoiler=a.spoiler, + ) return gallery async def to_files(self) -> list[File]: - """Converts this gallery to a list of :class:`discord.File` for use with :func:`discord.Messageable.send`. - """ + """Converts this gallery to a list of :class:`discord.File` for use with :func:`discord.Messageable.send`.""" return [await f.media.to_file() for f in self.items] @classmethod From 9974002aa6cd162a587acc4fa261cff416cc00a8 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:01:58 +0000 Subject: [PATCH 115/117] assets --- discord/ui/media_gallery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 489e3a3ee9..051fbdfb9f 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -202,7 +202,7 @@ def from_assets( If ``True``, uses local ``attachment://`` URLs instead of the CDN URLs. Ideal when reuploading assets. """ gallery = cls(id=id) - for a in attachments: + for a in assets: name = a.cdn_name if isinstance(a, AssetMixin) else a.filename url = f"attachment://{name}" if new else a.url gallery.add_item( From 3b643373bfd8d8160e1411a5ea0394836b80e3e0 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:08:09 +0000 Subject: [PATCH 116/117] , --- discord/ui/media_gallery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 051fbdfb9f..cf09913ac0 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -227,8 +227,8 @@ def from_files(cls: type[M], *files: File, id: int | None = None) -> M: for f in files: gallery.add_item( url=f"attachment://{f.filename}", - description=a.description, - spoiler=a.spoiler, + description=f.description, + spoiler=f.spoiler, ) return gallery From 67773c36b1bcd0854840fe4ba0c8c08c6cac2931 Mon Sep 17 00:00:00 2001 From: Nelo <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:26:16 +0100 Subject: [PATCH 117/117] meth --- discord/ui/media_gallery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index cf09913ac0..d6f9c2e8be 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -233,7 +233,7 @@ def from_files(cls: type[M], *files: File, id: int | None = None) -> M: return gallery async def to_files(self) -> list[File]: - """Converts this gallery to a list of :class:`discord.File` for use with :func:`discord.Messageable.send`.""" + """Converts this gallery to a list of :class:`discord.File` for use with :meth:`abc.Messageable.send`.""" return [await f.media.to_file() for f in self.items] @classmethod