From f5d1bb7a4a0354e1cee618a48cd7982a36d4d050 Mon Sep 17 00:00:00 2001 From: tandemdude <43570299+tandemdude@users.noreply.github.com> Date: Tue, 13 Aug 2024 14:27:03 +0100 Subject: [PATCH] fix: remove dataclasses from all context classes to fix weird super() behaviour --- docs/source/by-examples/100_appendix.md | 14 ++ lightbulb/__init__.py | 2 +- lightbulb/client.py | 10 +- lightbulb/components/__init__.py | 177 ++++++++++++++++++++++++ lightbulb/components/menus.py | 38 +++-- lightbulb/components/modals.py | 21 ++- lightbulb/context.py | 110 ++++++++++----- pyproject.toml | 9 +- 8 files changed, 313 insertions(+), 68 deletions(-) diff --git a/docs/source/by-examples/100_appendix.md b/docs/source/by-examples/100_appendix.md index fad5ae45..67decfd0 100644 --- a/docs/source/by-examples/100_appendix.md +++ b/docs/source/by-examples/100_appendix.md @@ -1 +1,15 @@ # Appendix + +--- + +## Components and Modals + +Lightbulb includes a component handler (and modal handler) that you can use to make processing component and modal +interactions easier than it otherwise would be using raw Hikari code. For a usage guide you should see the +documentation for the {obj}`components subpackage `. + +--- + +## Scheduled and Repeating Tasks + +TODO diff --git a/lightbulb/__init__.py b/lightbulb/__init__.py index 9b8de2af..8ff42ef6 100644 --- a/lightbulb/__init__.py +++ b/lightbulb/__init__.py @@ -18,7 +18,7 @@ # 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. -"""A simple-to-use command handler for Hikari.""" +"""A simple, elegant and powerful command handler for Hikari.""" from lightbulb import components from lightbulb import di diff --git a/lightbulb/client.py b/lightbulb/client.py index 6e8b5273..9a86422a 100644 --- a/lightbulb/client.py +++ b/lightbulb/client.py @@ -897,7 +897,7 @@ def build_autocomplete_context( Returns: :obj:`~lightbulb.context.AutocompleteContext`: The built context. """ - return context_.AutocompleteContext(self, interaction, options, command_cls) + return context_.AutocompleteContext(client=self, interaction=interaction, options=options, command=command_cls) async def _execute_autocomplete_context( self, context: context_.AutocompleteContext[t.Any], autocomplete_provider: options_.AutocompleteProvider[t.Any] @@ -1134,7 +1134,13 @@ def build_rest_autocomplete_context( command_cls: type[commands.CommandBase], response_callback: Callable[[hikari.api.InteractionResponseBuilder], None], ) -> context_.AutocompleteContext[t.Any]: - return context_.RestAutocompleteContext(self, interaction, options, command_cls, response_callback) + return context_.RestAutocompleteContext( + client=self, + interaction=interaction, + options=options, + command=command_cls, + _initial_response_callback=response_callback, + ) async def handle_rest_autocomplete_interaction( self, interaction: hikari.AutocompleteInteraction diff --git a/lightbulb/components/__init__.py b/lightbulb/components/__init__.py index ef875e90..2b7cab1c 100644 --- a/lightbulb/components/__init__.py +++ b/lightbulb/components/__init__.py @@ -18,6 +18,183 @@ # 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. +""" +This package contains a framework for creating your own component and modal handlers, without having to go +through the common issues when trying to do it using raw Hikari. + +---- + +Component Handling +------------------ + +Creating a Menu +^^^^^^^^^^^^^^^ + +Creating your own component handler is as easy as creating a subclass of the :obj:`~lightbulb.components.menus.Menu` +class. + +.. dropdown:: Example + + Creating a menu. + + .. code-block:: python + + import lightbulb + + class MyMenu(lightbulb.components.Menu): + def __init__(self) -> None: + ... + +A single menu class encapsulates the components and state that will be used when handling interactions for any +of the attached components. + +Adding Components to Menus +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +You can add components to a menu using any of the appropriate methods: + +- :meth:`~lightbulb.components.menus.Menu.add_interactive_button` +- :meth:`~lightbulb.components.menus.Menu.add_link_button` +- :meth:`~lightbulb.components.menus.Menu.add_text_select` +- :meth:`~lightbulb.components.menus.Menu.add_user_select` +- :meth:`~lightbulb.components.menus.Menu.add_role_select` +- :meth:`~lightbulb.components.menus.Menu.add_mentionable_select` +- :meth:`~lightbulb.components.menus.Menu.add_channel_select` + +The menu will lay out the added components into rows automatically. If you wish to customise the layout, you +can use the methods :meth:`~lightbulb.components.base.BuildableComponentContainer.next_row` and +:meth:`~lightbulb.components.base.BuildableComponentContainer.previous_row` to move between rows while adding +components. If a row becomes full (either through having five buttons, or one select), then the menu will +**always** move to the next row if you add another component to it. + +When adding a component to a menu, the methods return an object representing the created component. It is recommended +that you store this component within an instance variable so that you can modify it later if you wish to update +the menu's appearance. + +.. dropdown:: Example + + Adding a component to a menu. + + .. code-block:: python + + import lightbulb + + class MyMenu(lightbulb.components.Menu): + def __init__(self) -> None: + self.btn = self.add_interactive_button( + hikari.ButtonStyle.PRIMARY, + self.on_button_press, + label="Test Button", + ) + + async def on_button_press(self, ctx: lightbulb.components.MenuContext) -> None: + await ctx.respond("Button pressed!") + +Running Menus +^^^^^^^^^^^^^ + +To send a menu with a message, you can pass the menu instance to the ``components`` argument of the method you +are using (i.e. ``Context.respond``, ``RESTClient.create_message``) - it will be automatically built and sent +with the message. + +Menus require the Lightbulb :obj:`~lightbulb.client.Client` in order to listen for the appropriate interactions. You +can run a menu by calling the :meth:`~lightbulb.components.menus.Menu.attach` method. When calling this method, +you can optionally choose to wait until the menu completes before continuing, and pass a timeout after which +time an :obj:`asyncio.TimeoutError` will be raised. + +If you do not pass ``wait=True`` to the ``attach()`` method, then it is recommended that you pass your own known +custom IDs when you are adding components to the menu - otherwise they will be randomly generated and the menu will +probably not work as you intended. + +To get your ``Client`` instance within a command, you can use dependency injection as seen in the following example. +Check the "Dependencies" guide within the by-example section of the documentation for more details about dependency +injection. + +.. dropdown:: Example + + Attaching the menu to a client instance within a command. + + .. code-block:: python + + import lightbulb + + class MyMenu(lightbulb.components.Menu): + def __init__(self) -> None: + self.btn = self.add_interactive_button( + hikari.ButtonStyle.PRIMARY, + self.on_button_press, + label="Test Button", + ) + + async def on_button_press(self, ctx: lightbulb.components.MenuContext) -> None: + # Edit the message containing the buttons with the new content, and + # remove all the attached components. + await ctx.respond("Button pressed!", edit=True, components=[]) + # Stop listening for additional interactions for this menu + ctx.stop_interacting() + + class MyCommand(lightbulb.SlashCommand, name="test, description="test"): + @lightbulb.invoke + async def invoke(self, ctx: lightbulb.Context, client: lightbulb.Client) -> None: + menu = MyMenu() + resp = await ctx.respond("Menu testing", components=menu) + + # Run the menu, and catch a timeout if one occurs + try: + await menu.attach(client, wait=True, timeout=30) + except asyncio.TimeoutError: + await ctx.edit_respond(resp, "Timed out!", components=[]) + +.. warning:: + You should **always** pass a timeout, unless you wish the menu to be persistent. If you do not set a timeout, + then the number of active menus will grow forever, along with the memory usage of your program. + +.. warning:: + There are no checks added to menus by default to ensure that only one user can interact with any menu. If you + wish to restrict a menu to only a single user (or add other checks) you should pass any state to the menu + constructor and run your check at the top of each component callback. + +.. important:: + It is recommended that you create a new instance of your menu every time you send it for the first time - otherwise + multiple invocations could potentially interact with each other in unexpected ways. + +Once you have sent your menu, and it is processing interactions, you can safely modify the menu from within your +component callbacks in any way - change attributes of the components, add components, remove components, etc. If, +within a component callback, you wish to resend the menu with a response (after changing anything) - you can pass +``rebuild_menu=True``, or ``components=self`` to the context respond call . + +A Note on Select Components +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When adding a select menu to a component menu you **must** store it as an instance variable. If you do not do this +then getting the selected values for it will not be typed correctly. + +You can get the selected values for a select menu using the +:meth:`~lightbulb.components.menus.MenuContext.selected_values_for` method. + +.. dropdown:: Example + + .. code-block:: python + + import lightbulb + + class MyMenu(lightbulb.components.Menu): + def __init__(self) -> None: + self.select = self.add_text_select(["foo", "bar", "baz"], self.on_select) + + async def on_select(self, ctx: lightbulb.components.MenuContext) -> None: + await ctx.respond(f"Selected: {ctx.selected_values_for(self.select)}") + +---- + +Modal Handling +-------------- + +bar + +---- +""" + from lightbulb.components.base import * from lightbulb.components.menus import * from lightbulb.components.modals import * diff --git a/lightbulb/components/menus.py b/lightbulb/components/menus.py index 5d9d7c4a..d4bec260 100644 --- a/lightbulb/components/menus.py +++ b/lightbulb/components/menus.py @@ -38,7 +38,6 @@ import abc import asyncio -import dataclasses import typing as t import uuid from collections.abc import Sequence @@ -273,7 +272,7 @@ def add_to_row(self, row: special_endpoints.MessageActionRowBuilder) -> special_ ) -class MentionableSelect(Select[hikari.Snowflake]): +class MentionableSelect(Select[hikari.Unique]): """Class representing a select menu with snowflake options.""" __slots__ = () @@ -320,21 +319,34 @@ def add_to_row(self, row: special_endpoints.MessageActionRowBuilder) -> special_ ) -@dataclasses.dataclass(slots=True, kw_only=True) class MenuContext(base.MessageResponseMixinWithEdit[hikari.ComponentInteraction]): - """Dataclass representing the context for an invocation of a component that belongs to a menu.""" + """Class representing the context for an invocation of a component that belongs to a menu.""" - menu: Menu - """The menu that this context is for.""" - interaction: hikari.ComponentInteraction - """The interaction that this context is for.""" - component: base.BaseComponent[special_endpoints.MessageActionRowBuilder] - """The component that triggered the interaction for this context.""" + __slots__ = ("_interaction", "_should_re_resolve_custom_ids", "_should_stop_menu", "_timeout", "component", "menu") - _timeout: async_timeout.Timeout = dataclasses.field(repr=False) + def __init__( + self, + menu: Menu, + interaction: hikari.ComponentInteraction, + component: base.BaseComponent[special_endpoints.MessageActionRowBuilder], + _timeout: async_timeout.Timeout, + ) -> None: + super().__init__() - _should_stop_menu: bool = dataclasses.field(init=False, default=False, repr=False) - _should_re_resolve_custom_ids: bool = dataclasses.field(init=False, default=False, repr=False) + self.menu: Menu = menu + """The menu that this context is for.""" + self._interaction: hikari.ComponentInteraction = interaction + self.component: base.BaseComponent[special_endpoints.MessageActionRowBuilder] = component + """The component that triggered the interaction for this context.""" + + self._timeout: async_timeout.Timeout = _timeout + self._should_stop_menu: bool = False + self._should_re_resolve_custom_ids: bool = False + + @property + def interaction(self) -> hikari.ComponentInteraction: + """The interaction that this context is for.""" + return self._interaction @property def guild_id(self) -> hikari.Snowflake | None: diff --git a/lightbulb/components/modals.py b/lightbulb/components/modals.py index 01cd73ec..9e80dd90 100644 --- a/lightbulb/components/modals.py +++ b/lightbulb/components/modals.py @@ -27,7 +27,6 @@ import abc import asyncio -import dataclasses import typing as t import uuid @@ -95,14 +94,22 @@ def add_to_row(self, row: special_endpoints.ModalActionRowBuilder) -> special_en ) -@dataclasses.dataclass(slots=True, kw_only=True) class ModalContext(base.MessageResponseMixinWithEdit[hikari.ModalInteraction]): - """Dataclass representing the context for a modal interaction.""" + """Class representing the context for a modal interaction.""" - modal: Modal - """The modal this context is for.""" - interaction: hikari.ModalInteraction - """The interaction this context is for.""" + __slots__ = ("_interaction", "modal") + + def __init__(self, modal: Modal, interaction: hikari.ModalInteraction) -> None: + super().__init__() + + self.modal: Modal = modal + """The modal this context is for.""" + self._interaction: hikari.ModalInteraction = interaction + + @property + def interaction(self) -> hikari.ModalInteraction: + """The interaction this context is for.""" + return self._interaction @property def guild_id(self) -> hikari.Snowflake | None: diff --git a/lightbulb/context.py b/lightbulb/context.py index 4d17b57b..23c8a7c8 100644 --- a/lightbulb/context.py +++ b/lightbulb/context.py @@ -27,7 +27,6 @@ import abc import asyncio -import dataclasses import os import typing as t from collections.abc import Callable @@ -57,22 +56,28 @@ ] -@dataclasses.dataclass(slots=True) class AutocompleteContext(t.Generic[T]): - """Dataclass representing the context for an autocomplete interaction.""" + """Class representing the context for an autocomplete interaction.""" - client: client_.Client - """The client that created the context.""" + __slots__ = ("_focused", "client", "command", "interaction", "options") - interaction: hikari.AutocompleteInteraction - """The interaction for the autocomplete invocation.""" - options: Sequence[hikari.AutocompleteInteractionOption] - """The options provided with the autocomplete interaction.""" - - command: type[commands.CommandBase] - """Command class for the autocomplete invocation.""" + def __init__( + self, + client: client_.Client, + interaction: hikari.AutocompleteInteraction, + options: Sequence[hikari.AutocompleteInteractionOption], + command: type[commands.CommandBase], + ) -> None: + self.client: client_.Client = client + """The client that created the context.""" + self.interaction: hikari.AutocompleteInteraction = interaction + """The interaction for the autocomplete invocation.""" + self.options: Sequence[hikari.AutocompleteInteractionOption] = options + """The options provided with the autocomplete interaction.""" + self.command: type[commands.CommandBase] = command + """Command class for the autocomplete invocation.""" - _focused: hikari.AutocompleteInteractionOption | None = dataclasses.field(init=False, default=None) + self._focused: hikari.AutocompleteInteractionOption | None = None @property def focused(self) -> hikari.AutocompleteInteractionOption: @@ -142,24 +147,35 @@ async def respond(self, choices: AutocompleteResponse[T]) -> None: await self.interaction.create_response(normalised_choices) -@dataclasses.dataclass(slots=True) class RestAutocompleteContext(AutocompleteContext[T]): - _initial_response_callback: Callable[ - [hikari.api.InteractionAutocompleteBuilder], - None, - ] + __slots__ = ("_initial_response_callback",) + + def __init__( + self, + *args: t.Any, + _initial_response_callback: Callable[ + [hikari.api.InteractionAutocompleteBuilder], + None, + ], + **kwargs: t.Any, + ) -> None: + super().__init__(*args, **kwargs) + + self._initial_response_callback = _initial_response_callback async def respond(self, choices: AutocompleteResponse[T]) -> None: normalised_choices = self._normalise_choices(choices) self._initial_response_callback(special_endpoints_impl.InteractionAutocompleteBuilder(normalised_choices)) -@dataclasses.dataclass(slots=True, kw_only=True) class MessageResponseMixin(abc.ABC, t.Generic[RespondableInteractionT]): """Abstract mixin for contexts that allow creating responses to interactions.""" - _response_lock: asyncio.Lock = dataclasses.field(init=False, default_factory=asyncio.Lock) - _initial_response_sent: bool = dataclasses.field(init=False, default=False) + __slots__ = ("_initial_response_sent", "_response_lock") + + def __init__(self) -> None: + self._response_lock: asyncio.Lock = asyncio.Lock() + self._initial_response_sent: bool = False @property @abc.abstractmethod @@ -412,24 +428,35 @@ async def respond( ).id -@dataclasses.dataclass(slots=True, kw_only=True) class Context(MessageResponseMixin[hikari.CommandInteraction]): - """Dataclass representing the context for a single command invocation.""" + """Class representing the context for a single command invocation.""" - client: client_.Client - """The client that created the context.""" + __slots__ = ("_interaction", "client", "command", "options") - interaction: hikari.CommandInteraction - """The interaction for the command invocation.""" - options: Sequence[hikari.CommandInteractionOption] - """The options to use for the command invocation.""" + def __init__( + self, + client: client_.Client, + interaction: hikari.CommandInteraction, + options: Sequence[hikari.CommandInteractionOption], + command: commands.CommandBase, + ) -> None: + super().__init__() - command: commands.CommandBase - """Command instance for the command invocation.""" + self.client: client_.Client = client + """The client that created the context.""" + self._interaction: hikari.CommandInteraction = interaction + self.options: Sequence[hikari.CommandInteractionOption] = options + """The options to use for the command invocation.""" + self.command: commands.CommandBase = command + """Command instance for the command invocation.""" - def __post_init__(self) -> None: self.command._set_context(self) + @property + def interaction(self) -> hikari.CommandInteraction: + """The interaction for the command invocation.""" + return self._interaction + @property def guild_id(self) -> hikari.Snowflake | None: """The ID of the guild that the command was invoked in. :obj:`None` if the invocation occurred in DM.""" @@ -485,12 +512,21 @@ async def respond_with_modal( self._initial_response_sent = True -@dataclasses.dataclass(slots=True, kw_only=True) class RestContext(Context): - _initial_response_callback: Callable[ - [hikari.api.InteractionResponseBuilder], - None, - ] + __slots__ = ("_initial_response_callback",) + + def __init__( + self, + *args: t.Any, + _initial_response_callback: Callable[ + [hikari.api.InteractionResponseBuilder], + None, + ], + **kwargs: t.Any, + ) -> None: + super().__init__(*args, **kwargs) + + self._initial_response_callback = _initial_response_callback async def respond_with_modal( self, diff --git a/pyproject.toml b/pyproject.toml index 2549dfe4..76766909 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -157,16 +157,9 @@ convention = "google" strict-imports = true require-superclass = true require-subclass = true -# The classes from MenuContext to Context are only due to a dataclasses slots -# bug with Python 3.10 - they can be removed once the minimum Python version is bumped to 3.11 exclude-classes = """ ( - ^lightbulb\\.commands\\.commands:CommandMeta$ | - ^lightbulb\\.context:RestAutocompleteContext$ | - ^lightbulb\\.context:RestContext$ | - ^lightbulb\\.components\\.menus:MenuContext$ | - ^lightbulb\\.components\\.modals:ModalContext$ | - ^lightbulb\\.context:Context$ + ^lightbulb\\.commands\\.commands:CommandMeta$ ) """