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 01/46] 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 02/46] 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 03/46] 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 04/46] 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 05/46] 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 06/46] 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 07/46] 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 08/46] , --- 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 09/46] 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 10/46] 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 11/46] 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 12/46] 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 13/46] 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 14/46] 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 15/46] 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 16/46] 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 17/46] 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 18/46] 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 19/46] 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 20/46] 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 21/46] 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 22/46] 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 23/46] 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 24/46] 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 25/46] 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 26/46] 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 27/46] 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 28/46] 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 29/46] 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 30/46] 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 31/46] 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 32/46] 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 33/46] 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 34/46] 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 35/46] 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 36/46] 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 37/46] 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 38/46] 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 39/46] 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 40/46] 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 41/46] 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 42/46] 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 43/46] 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 44/46] 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 45/46] 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 46/46] 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,