From 4cdb04910ed7b37af76160893ff7343e30384755 Mon Sep 17 00:00:00 2001 From: AstreaTSS <25420078+AstreaTSS@users.noreply.github.com> Date: Wed, 5 Jul 2023 01:42:46 -0400 Subject: [PATCH] feat: add alt methods for multi-arg params for prefixed cmds --- docs/src/Guides/26 Prefixed Commands.md | 54 +++++++++++----- ...{KeywordParam.png => ConsumeRestParam.png} | Bin ...thQuotes.png => ConsumeRestWithQuotes.png} | Bin interactions/__init__.py | 2 + .../ext/hybrid_commands/hybrid_slash.py | 20 +++++- interactions/ext/prefixed_commands/command.py | 60 +++++++++++++----- interactions/models/__init__.py | 2 + interactions/models/internal/__init__.py | 2 + interactions/models/internal/converters.py | 14 +++- 9 files changed, 118 insertions(+), 36 deletions(-) rename docs/src/images/PrefixedCommands/{KeywordParam.png => ConsumeRestParam.png} (100%) rename docs/src/images/PrefixedCommands/{KeywordParamWithQuotes.png => ConsumeRestWithQuotes.png} (100%) diff --git a/docs/src/Guides/26 Prefixed Commands.md b/docs/src/Guides/26 Prefixed Commands.md index 11e62e1dc..e58d24b15 100644 --- a/docs/src/Guides/26 Prefixed Commands.md +++ b/docs/src/Guides/26 Prefixed Commands.md @@ -100,18 +100,27 @@ async def test(ctx: PrefixedContext, arg1, arg2): ![Two Parameters](../images/PrefixedCommands/TwoParams.png "The above running with the arguments: one two") -### Variable and Keyword-Only Arguments +### Variable and Consume Rest Arguments There may be times where you wish for an argument to be able to have multiple words without wrapping them in quotes. There are two ways of approaching this. #### Variable If you wish to get a list (or more specifically, a tuple) of words for one argument, or simply want an undetermined amount of arguments for a command, then you should use a *variable* argument: -```python -@prefixed_command() -async def test(ctx: PrefixedContext, *args): - await ctx.reply(f"{len(args)} arguments: {', '.join(args)}") -``` + +=== ":one: Tuple Argument" + ```python + @prefixed_command() + async def test(ctx: PrefixedContext, args: tuple[str, ...]): + await ctx.reply(f"{len(args)} arguments: {', '.join(args)}") + ``` + +=== ":two: Variable Positional Argument" + ```python + @prefixed_command() + async def test(ctx: PrefixedContext, *args): + await ctx.reply(f"{len(args)} arguments: {', '.join(args)}") + ``` The result looks something like this: @@ -119,26 +128,37 @@ The result looks something like this: Notice how the quoted words are still parsed as one argument in the tuple. -#### Keyword-Only +#### Consume Rest -If you simply wish to take in the rest of the user's input as an argument, you can use a keyword-only argument, like so: -```python -@prefixed_command() -async def test(ctx: PrefixedContext, *, arg): - await ctx.reply(arg) -``` +If you simply wish to take in the rest of the user's input as an argument, you can use a consume rest argument, like so: + +=== ":one: ConsumeRest Alias" + ```python + from interactions import ConsumeRest + + @prefixed_command() + async def test(ctx: PrefixedContext, arg: ConsumeRest[str]): + await ctx.reply(arg) + ``` + +=== ":two: Keyword-only Argument" + ```python + @prefixed_command() + async def test(ctx: PrefixedContext, *, arg): + await ctx.reply(arg) + ``` The result looks like this: -![Keyword-Only Parameter](../images/PrefixedCommands/KeywordParam.png "The above running with the arguments: hello world!") +![Consume Rest Parameter](../images/PrefixedCommands/ConsumeRestParam.png "The above running with the arguments: hello world!") ???+ note "Quotes" - If a user passes quotes into a keyword-only argument, then the resulting argument will have said quotes. + If a user passes quotes into consume rest argument, then the resulting argument will have said quotes. - ![Keyword-Only Quotes](../images/PrefixedCommands/KeywordParamWithQuotes.png "The above running with the arguments: "hello world!"") + ![Consume Rest Quotes](../images/PrefixedCommands/ConsumeRestWithQuotes.png "The above running with the arguments: "hello world!"") !!! warning "Parser ambiguities" - Due to parser ambiguities, you can *only* have either a single variable or keyword-only/consume rest argument. + Due to parser ambiguities, you can *only* have either a single variable or consume rest argument. ## Typehinting and Converters diff --git a/docs/src/images/PrefixedCommands/KeywordParam.png b/docs/src/images/PrefixedCommands/ConsumeRestParam.png similarity index 100% rename from docs/src/images/PrefixedCommands/KeywordParam.png rename to docs/src/images/PrefixedCommands/ConsumeRestParam.png diff --git a/docs/src/images/PrefixedCommands/KeywordParamWithQuotes.png b/docs/src/images/PrefixedCommands/ConsumeRestWithQuotes.png similarity index 100% rename from docs/src/images/PrefixedCommands/KeywordParamWithQuotes.png rename to docs/src/images/PrefixedCommands/ConsumeRestWithQuotes.png diff --git a/interactions/__init__.py b/interactions/__init__.py index 3be1785e8..c3f15e947 100644 --- a/interactions/__init__.py +++ b/interactions/__init__.py @@ -103,6 +103,7 @@ ComponentCommand, ComponentContext, ComponentType, + ConsumeRest, context_menu, ContextMenu, ContextMenuContext, @@ -412,6 +413,7 @@ "ComponentCommand", "ComponentContext", "ComponentType", + "ConsumeRest", "const", "context_menu", "CONTEXT_MENU_NAME_LENGTH", diff --git a/interactions/ext/hybrid_commands/hybrid_slash.py b/interactions/ext/hybrid_commands/hybrid_slash.py index dc848c8a5..6136f303a 100644 --- a/interactions/ext/hybrid_commands/hybrid_slash.py +++ b/interactions/ext/hybrid_commands/hybrid_slash.py @@ -1,11 +1,12 @@ import asyncio import inspect -from typing import Any, Callable, List, Optional, Union, TYPE_CHECKING, Awaitable +from typing import Any, Callable, List, Optional, Union, TYPE_CHECKING, Awaitable, Annotated, get_origin, get_args import attrs from interactions import ( BaseContext, Converter, + ConsumeRest, NoArgumentConverter, Attachment, SlashCommandChoice, @@ -32,7 +33,7 @@ from interactions.client.utils.misc_utils import maybe_coroutine, get_object_name from interactions.client.errors import BadArgument from interactions.ext.prefixed_commands import PrefixedCommand, PrefixedContext -from interactions.models.internal.converters import _LiteralConverter +from interactions.models.internal.converters import _LiteralConverter, CONSUME_REST_MARKER from interactions.models.internal.checks import guild_only if TYPE_CHECKING: @@ -360,12 +361,24 @@ def slash_to_prefixed(cmd: HybridSlashCommand) -> _HybridToPrefixedCommand: # n default = inspect.Parameter.empty kind = inspect.Parameter.POSITIONAL_ONLY if cmd._uses_arg else inspect.Parameter.POSITIONAL_OR_KEYWORD + consume_rest: bool = False + if slash_param := cmd.parameters.get(name): kind = slash_param.kind if kind == inspect.Parameter.KEYWORD_ONLY: # work around prefixed cmd parsing kind = inspect.Parameter.POSITIONAL_OR_KEYWORD + # here come the hacks - these allow ConsumeRest (the class) to be passed through + if get_origin(slash_param.type) == Annotated: + args = get_args(slash_param.type) + # ComsumeRest[str] or Annotated[ConsumeRest[str], Converter] support + # by all means, the second isn't allowed in prefixed commands, but we'll ignore that for converter support for slash cmds + if args[1] is CONSUME_REST_MARKER or ( + args[0] == Annotated and get_args(args[0])[1] is CONSUME_REST_MARKER + ): + consume_rest = True + if slash_param.converter: annotation = slash_param.converter if slash_param.default is not MISSING: @@ -392,6 +405,9 @@ def slash_to_prefixed(cmd: HybridSlashCommand) -> _HybridToPrefixedCommand: # n if not option.required and default == inspect.Parameter.empty: default = None + if consume_rest: + annotation = ConsumeRest[annotation] + actual_param = inspect.Parameter( name=name, kind=kind, diff --git a/interactions/ext/prefixed_commands/command.py b/interactions/ext/prefixed_commands/command.py index de878b31a..a75ef140b 100644 --- a/interactions/ext/prefixed_commands/command.py +++ b/interactions/ext/prefixed_commands/command.py @@ -27,6 +27,7 @@ _LiteralConverter, NoArgumentConverter, Greedy, + CONSUME_REST_MARKER, MODEL_TO_CONVERTER, ) from interactions.models.internal.protocols import Converter @@ -62,6 +63,7 @@ class PrefixedCommandParameter: "union", "variable", "consume_rest", + "consume_rest_class", "no_argument", ) @@ -499,13 +501,33 @@ def _parse_parameters(self) -> None: # noqa: C901 cmd_param = PrefixedCommandParameter.from_param(param) anno = param.annotation + # this is ugly, ik + if typing.get_origin(anno) == Annotated and typing.get_args(anno)[1] is CONSUME_REST_MARKER: + cmd_param.consume_rest = True + finished_params = True + anno = typing.get_args(anno)[0] + + if typing.get_origin(anno) == Annotated: + anno = _get_from_anno_type(anno) + if typing.get_origin(anno) == Greedy: + if finished_params: + raise ValueError("Consume rest arguments cannot be Greedy.") + anno, default = _greedy_parse(anno, param) if default is not param.empty: cmd_param.default = default cmd_param.greedy = True + if typing.get_origin(anno) == tuple: + if cmd_param.optional: + # there's a lot of parser ambiguities here, so i'd rather not + raise ValueError("Variable arguments cannot have default values or be Optional.") + cmd_param.variable = True + finished_params = True + anno = typing.get_args(anno)[0] + if typing.get_origin(anno) in {Union, UnionType}: cmd_param.union = True for arg in typing.get_args(anno): @@ -524,22 +546,23 @@ def _parse_parameters(self) -> None: # noqa: C901 converter = _get_converter(anno, name) cmd_param.converters.append(converter) - match param.kind: - case param.KEYWORD_ONLY: - if cmd_param.greedy: - raise ValueError("Keyword-only arguments cannot be Greedy.") + if not finished_params: + match param.kind: + case param.KEYWORD_ONLY: + if cmd_param.greedy: + raise ValueError("Consume rest arguments cannot be Greedy.") - cmd_param.consume_rest = True - finished_params = True - case param.VAR_POSITIONAL: - if cmd_param.optional: - # there's a lot of parser ambiguities here, so i'd rather not - raise ValueError("Variable arguments cannot have default values or be Optional.") - if cmd_param.greedy: - raise ValueError("Variable arguments cannot be Greedy.") + cmd_param.consume_rest = True + finished_params = True + case param.VAR_POSITIONAL: + if cmd_param.optional: + # there's a lot of parser ambiguities here, so i'd rather not + raise ValueError("Variable arguments cannot have default values or be Optional.") + if cmd_param.greedy: + raise ValueError("Variable arguments cannot be Greedy.") - cmd_param.variable = True - finished_params = True + cmd_param.variable = True + finished_params = True self.parameters.append(cmd_param) @@ -698,7 +721,14 @@ async def call_callback(self, callback: Callable, ctx: "PrefixedContext") -> Non args_to_convert = args.get_rest_of_args() new_arg = [await _convert(param, ctx, arg) for arg in args_to_convert] new_arg = tuple(arg[0] for arg in new_arg) - new_args.extend(new_arg) + + if param.kind == inspect.Parameter.VAR_POSITIONAL: + new_args.extend(new_arg) + elif param.kind == inspect.Parameter.POSITIONAL_ONLY: + new_args.append(new_arg) + else: + kwargs[param.name] = new_arg + param_index += 1 break diff --git a/interactions/models/__init__.py b/interactions/models/__init__.py index a3e3543aa..5341c644f 100644 --- a/interactions/models/__init__.py +++ b/interactions/models/__init__.py @@ -209,6 +209,7 @@ context_menu, user_context_menu, message_context_menu, + ConsumeRest, ContextMenu, ContextMenuContext, Converter, @@ -361,6 +362,7 @@ "ComponentCommand", "ComponentContext", "ComponentType", + "ConsumeRest", "context_menu", "ContextMenu", "ContextMenuContext", diff --git a/interactions/models/internal/__init__.py b/interactions/models/internal/__init__.py index 0570d3a11..ef6e311cb 100644 --- a/interactions/models/internal/__init__.py +++ b/interactions/models/internal/__init__.py @@ -58,6 +58,7 @@ from .converters import ( BaseChannelConverter, ChannelConverter, + ConsumeRest, CustomEmojiConverter, DMChannelConverter, DMConverter, @@ -124,6 +125,7 @@ "context_menu", "user_context_menu", "message_context_menu", + "ConsumeRest", "ContextMenu", "ContextMenuContext", "Converter", diff --git a/interactions/models/internal/converters.py b/interactions/models/internal/converters.py index 932f47d0e..7a638ff69 100644 --- a/interactions/models/internal/converters.py +++ b/interactions/models/internal/converters.py @@ -1,8 +1,8 @@ import re import typing -from typing import Any, Optional, List +from typing import Any, Optional, List, Annotated -from interactions.client.const import T, T_co +from interactions.client.const import T, T_co, Sentinel from interactions.client.errors import BadArgument from interactions.client.errors import Forbidden, HTTPException from interactions.models.discord.channel import ( @@ -66,6 +66,7 @@ "CustomEmojiConverter", "MessageConverter", "Greedy", + "ConsumeRest", "MODEL_TO_CONVERTER", ) @@ -572,6 +573,15 @@ class Greedy(List[T]): """A special marker class to mark an argument in a prefixed command to repeatedly convert until it fails to convert an argument.""" +class ConsumeRestMarker(Sentinel): + pass + + +CONSUME_REST_MARKER = ConsumeRestMarker() + +ConsumeRest = Annotated[T, CONSUME_REST_MARKER] +"""A special marker type alias to mark an argument in a prefixed command to consume the rest of the arguments.""" + MODEL_TO_CONVERTER: dict[type, type[Converter]] = { SnowflakeObject: SnowflakeConverter, BaseChannel: BaseChannelConverter,