Skip to content

Commit

Permalink
feat: add alt methods for multi-arg params for prefixed cmds
Browse files Browse the repository at this point in the history
  • Loading branch information
AstreaTSS committed Jul 5, 2023
1 parent 1d5a99f commit 4cdb049
Show file tree
Hide file tree
Showing 9 changed files with 118 additions and 36 deletions.
54 changes: 37 additions & 17 deletions docs/src/Guides/26 Prefixed Commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,45 +100,65 @@ 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:

![Variable Parameter](../images/PrefixedCommands/VariableParam.png "The above running with the arguments: hello there world "how are you?"")

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

Expand Down
2 changes: 2 additions & 0 deletions interactions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
ComponentCommand,
ComponentContext,
ComponentType,
ConsumeRest,
context_menu,
ContextMenu,
ContextMenuContext,
Expand Down Expand Up @@ -412,6 +413,7 @@
"ComponentCommand",
"ComponentContext",
"ComponentType",
"ConsumeRest",
"const",
"context_menu",
"CONTEXT_MENU_NAME_LENGTH",
Expand Down
20 changes: 18 additions & 2 deletions interactions/ext/hybrid_commands/hybrid_slash.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down
60 changes: 45 additions & 15 deletions interactions/ext/prefixed_commands/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
_LiteralConverter,
NoArgumentConverter,
Greedy,
CONSUME_REST_MARKER,
MODEL_TO_CONVERTER,
)
from interactions.models.internal.protocols import Converter
Expand Down Expand Up @@ -62,6 +63,7 @@ class PrefixedCommandParameter:
"union",
"variable",
"consume_rest",
"consume_rest_class",
"no_argument",
)

Expand Down Expand Up @@ -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):
Expand All @@ -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)

Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions interactions/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@
context_menu,
user_context_menu,
message_context_menu,
ConsumeRest,
ContextMenu,
ContextMenuContext,
Converter,
Expand Down Expand Up @@ -361,6 +362,7 @@
"ComponentCommand",
"ComponentContext",
"ComponentType",
"ConsumeRest",
"context_menu",
"ContextMenu",
"ContextMenuContext",
Expand Down
2 changes: 2 additions & 0 deletions interactions/models/internal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from .converters import (
BaseChannelConverter,
ChannelConverter,
ConsumeRest,
CustomEmojiConverter,
DMChannelConverter,
DMConverter,
Expand Down Expand Up @@ -124,6 +125,7 @@
"context_menu",
"user_context_menu",
"message_context_menu",
"ConsumeRest",
"ContextMenu",
"ContextMenuContext",
"Converter",
Expand Down
14 changes: 12 additions & 2 deletions interactions/models/internal/converters.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -66,6 +66,7 @@
"CustomEmojiConverter",
"MessageConverter",
"Greedy",
"ConsumeRest",
"MODEL_TO_CONVERTER",
)

Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 4cdb049

Please sign in to comment.