Skip to content

Commit e828fb5

Browse files
feat: add alt methods for multi-arg params for prefixed cmds (#1471)
* feat: add alt methods for multi-arg params for prefixed cmds * fix: use empty if the typehint is just tuple * ci: correct from checks. * fix: support ConsumeRest without typehint --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent c5c5045 commit e828fb5

File tree

9 files changed

+125
-37
lines changed

9 files changed

+125
-37
lines changed

docs/src/Guides/26 Prefixed Commands.md

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -100,45 +100,65 @@ async def test(ctx: PrefixedContext, arg1, arg2):
100100

101101
![Two Parameters](../images/PrefixedCommands/TwoParams.png "The above running with the arguments: one two")
102102

103-
### Variable and Keyword-Only Arguments
103+
### Variable and Consume Rest Arguments
104104

105105
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.
106106

107107
#### Variable
108108

109109
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:
110-
```python
111-
@prefixed_command()
112-
async def test(ctx: PrefixedContext, *args):
113-
await ctx.reply(f"{len(args)} arguments: {', '.join(args)}")
114-
```
110+
111+
=== ":one: Tuple Argument"
112+
```python
113+
@prefixed_command()
114+
async def test(ctx: PrefixedContext, args: tuple[str, ...]):
115+
await ctx.reply(f"{len(args)} arguments: {', '.join(args)}")
116+
```
117+
118+
=== ":two: Variable Positional Argument"
119+
```python
120+
@prefixed_command()
121+
async def test(ctx: PrefixedContext, *args):
122+
await ctx.reply(f"{len(args)} arguments: {', '.join(args)}")
123+
```
115124

116125
The result looks something like this:
117126

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

120129
Notice how the quoted words are still parsed as one argument in the tuple.
121130

122-
#### Keyword-Only
131+
#### Consume Rest
123132

124-
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:
125-
```python
126-
@prefixed_command()
127-
async def test(ctx: PrefixedContext, *, arg):
128-
await ctx.reply(arg)
129-
```
133+
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:
134+
135+
=== ":one: ConsumeRest Alias"
136+
```python
137+
from interactions import ConsumeRest
138+
139+
@prefixed_command()
140+
async def test(ctx: PrefixedContext, arg: ConsumeRest[str]):
141+
await ctx.reply(arg)
142+
```
143+
144+
=== ":two: Keyword-only Argument"
145+
```python
146+
@prefixed_command()
147+
async def test(ctx: PrefixedContext, *, arg):
148+
await ctx.reply(arg)
149+
```
130150

131151
The result looks like this:
132152

133-
![Keyword-Only Parameter](../images/PrefixedCommands/KeywordParam.png "The above running with the arguments: hello world!")
153+
![Consume Rest Parameter](../images/PrefixedCommands/ConsumeRestParam.png "The above running with the arguments: hello world!")
134154

135155
???+ note "Quotes"
136-
If a user passes quotes into a keyword-only argument, then the resulting argument will have said quotes.
156+
If a user passes quotes into consume rest argument, then the resulting argument will have said quotes.
137157

138-
![Keyword-Only Quotes](../images/PrefixedCommands/KeywordParamWithQuotes.png "The above running with the arguments: "hello world!"")
158+
![Consume Rest Quotes](../images/PrefixedCommands/ConsumeRestWithQuotes.png "The above running with the arguments: "hello world!"")
139159

140160
!!! warning "Parser ambiguities"
141-
Due to parser ambiguities, you can *only* have either a single variable or keyword-only/consume rest argument.
161+
Due to parser ambiguities, you can *only* have either a single variable or consume rest argument.
142162

143163
## Typehinting and Converters
144164

File renamed without changes.
File renamed without changes.

interactions/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
ComponentCommand,
104104
ComponentContext,
105105
ComponentType,
106+
ConsumeRest,
106107
context_menu,
107108
ContextMenu,
108109
ContextMenuContext,
@@ -413,6 +414,7 @@
413414
"ComponentCommand",
414415
"ComponentContext",
415416
"ComponentType",
417+
"ConsumeRest",
416418
"const",
417419
"context_menu",
418420
"CONTEXT_MENU_NAME_LENGTH",

interactions/ext/hybrid_commands/hybrid_slash.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import asyncio
22
import inspect
3-
from typing import Any, Callable, List, Optional, Union, TYPE_CHECKING, Awaitable
3+
from typing import Any, Callable, List, Optional, Union, TYPE_CHECKING, Awaitable, Annotated, get_origin, get_args
44

55
import attrs
66
from interactions import (
77
BaseContext,
88
Converter,
9+
ConsumeRest,
910
NoArgumentConverter,
1011
Attachment,
1112
SlashCommandChoice,
@@ -31,7 +32,7 @@
3132
from interactions.client.utils.misc_utils import maybe_coroutine, get_object_name
3233
from interactions.client.errors import BadArgument
3334
from interactions.ext.prefixed_commands import PrefixedCommand, PrefixedContext
34-
from interactions.models.internal.converters import _LiteralConverter
35+
from interactions.models.internal.converters import _LiteralConverter, CONSUME_REST_MARKER
3536
from interactions.models.internal.checks import guild_only
3637

3738
if TYPE_CHECKING:
@@ -355,12 +356,24 @@ def slash_to_prefixed(cmd: HybridSlashCommand) -> _HybridToPrefixedCommand: # n
355356
default = inspect.Parameter.empty
356357
kind = inspect.Parameter.POSITIONAL_ONLY if cmd._uses_arg else inspect.Parameter.POSITIONAL_OR_KEYWORD
357358

359+
consume_rest: bool = False
360+
358361
if slash_param := cmd.parameters.get(name):
359362
kind = slash_param.kind
360363

361364
if kind == inspect.Parameter.KEYWORD_ONLY: # work around prefixed cmd parsing
362365
kind = inspect.Parameter.POSITIONAL_OR_KEYWORD
363366

367+
# here come the hacks - these allow ConsumeRest (the class) to be passed through
368+
if get_origin(slash_param.type) == Annotated:
369+
args = get_args(slash_param.type)
370+
# ComsumeRest[str] or Annotated[ConsumeRest[str], Converter] support
371+
# by all means, the second isn't allowed in prefixed commands, but we'll ignore that for converter support for slash cmds
372+
if args[1] is CONSUME_REST_MARKER or (
373+
args[0] == Annotated and get_args(args[0])[1] is CONSUME_REST_MARKER
374+
):
375+
consume_rest = True
376+
364377
if slash_param.converter:
365378
annotation = slash_param.converter
366379
if slash_param.default is not MISSING:
@@ -387,6 +400,9 @@ def slash_to_prefixed(cmd: HybridSlashCommand) -> _HybridToPrefixedCommand: # n
387400
if not option.required and default == inspect.Parameter.empty:
388401
default = None
389402

403+
if consume_rest:
404+
annotation = ConsumeRest[annotation]
405+
390406
actual_param = inspect.Parameter(
391407
name=name,
392408
kind=kind,

interactions/ext/prefixed_commands/command.py

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import attrs
1919
from typing_extensions import Self
2020

21-
from interactions.client.const import MISSING
21+
from interactions.client.const import MISSING, T
2222
from interactions.client.errors import BadArgument
2323
from interactions.client.utils.input_utils import _quotes
2424
from interactions.client.utils.misc_utils import get_object_name, maybe_coroutine
@@ -27,6 +27,7 @@
2727
_LiteralConverter,
2828
NoArgumentConverter,
2929
Greedy,
30+
CONSUME_REST_MARKER,
3031
MODEL_TO_CONVERTER,
3132
)
3233
from interactions.models.internal.protocols import Converter
@@ -62,6 +63,7 @@ class PrefixedCommandParameter:
6263
"union",
6364
"variable",
6465
"consume_rest",
66+
"consume_rest_class",
6567
"no_argument",
6668
)
6769

@@ -499,13 +501,39 @@ def _parse_parameters(self) -> None: # noqa: C901
499501
cmd_param = PrefixedCommandParameter.from_param(param)
500502
anno = param.annotation
501503

504+
# this is ugly, ik
505+
if typing.get_origin(anno) == Annotated and typing.get_args(anno)[1] is CONSUME_REST_MARKER:
506+
cmd_param.consume_rest = True
507+
finished_params = True
508+
anno = typing.get_args(anno)[0]
509+
510+
if anno == T:
511+
# someone forgot to typehint
512+
anno = inspect._empty
513+
514+
if typing.get_origin(anno) == Annotated:
515+
anno = _get_from_anno_type(anno)
516+
502517
if typing.get_origin(anno) == Greedy:
518+
if finished_params:
519+
raise ValueError("Consume rest arguments cannot be Greedy.")
520+
503521
anno, default = _greedy_parse(anno, param)
504522

505523
if default is not param.empty:
506524
cmd_param.default = default
507525
cmd_param.greedy = True
508526

527+
if typing.get_origin(anno) == tuple:
528+
if cmd_param.optional:
529+
# there's a lot of parser ambiguities here, so i'd rather not
530+
raise ValueError("Variable arguments cannot have default values or be Optional.")
531+
cmd_param.variable = True
532+
finished_params = True
533+
534+
# use empty if the typehint is just "tuple"
535+
anno = typing.get_args(anno)[0] if typing.get_args(anno) else inspect._empty
536+
509537
if typing.get_origin(anno) in {Union, UnionType}:
510538
cmd_param.union = True
511539
for arg in typing.get_args(anno):
@@ -524,22 +552,23 @@ def _parse_parameters(self) -> None: # noqa: C901
524552
converter = _get_converter(anno, name)
525553
cmd_param.converters.append(converter)
526554

527-
match param.kind:
528-
case param.KEYWORD_ONLY:
529-
if cmd_param.greedy:
530-
raise ValueError("Keyword-only arguments cannot be Greedy.")
555+
if not finished_params:
556+
match param.kind:
557+
case param.KEYWORD_ONLY:
558+
if cmd_param.greedy:
559+
raise ValueError("Consume rest arguments cannot be Greedy.")
531560

532-
cmd_param.consume_rest = True
533-
finished_params = True
534-
case param.VAR_POSITIONAL:
535-
if cmd_param.optional:
536-
# there's a lot of parser ambiguities here, so i'd rather not
537-
raise ValueError("Variable arguments cannot have default values or be Optional.")
538-
if cmd_param.greedy:
539-
raise ValueError("Variable arguments cannot be Greedy.")
561+
cmd_param.consume_rest = True
562+
finished_params = True
563+
case param.VAR_POSITIONAL:
564+
if cmd_param.optional:
565+
# there's a lot of parser ambiguities here, so i'd rather not
566+
raise ValueError("Variable arguments cannot have default values or be Optional.")
567+
if cmd_param.greedy:
568+
raise ValueError("Variable arguments cannot be Greedy.")
540569

541-
cmd_param.variable = True
542-
finished_params = True
570+
cmd_param.variable = True
571+
finished_params = True
543572

544573
self.parameters.append(cmd_param)
545574

@@ -698,7 +727,14 @@ async def call_callback(self, callback: Callable, ctx: "PrefixedContext") -> Non
698727
args_to_convert = args.get_rest_of_args()
699728
new_arg = [await _convert(param, ctx, arg) for arg in args_to_convert]
700729
new_arg = tuple(arg[0] for arg in new_arg)
701-
new_args.extend(new_arg)
730+
731+
if param.kind == inspect.Parameter.VAR_POSITIONAL:
732+
new_args.extend(new_arg)
733+
elif param.kind == inspect.Parameter.POSITIONAL_ONLY:
734+
new_args.append(new_arg)
735+
else:
736+
kwargs[param.name] = new_arg
737+
702738
param_index += 1
703739
break
704740

interactions/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@
210210
context_menu,
211211
user_context_menu,
212212
message_context_menu,
213+
ConsumeRest,
213214
ContextMenu,
214215
ContextMenuContext,
215216
Converter,
@@ -362,6 +363,7 @@
362363
"ComponentCommand",
363364
"ComponentContext",
364365
"ComponentType",
366+
"ConsumeRest",
365367
"context_menu",
366368
"ContextMenu",
367369
"ContextMenuContext",

interactions/models/internal/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
from .converters import (
5959
BaseChannelConverter,
6060
ChannelConverter,
61+
ConsumeRest,
6162
CustomEmojiConverter,
6263
DMChannelConverter,
6364
DMConverter,
@@ -124,6 +125,7 @@
124125
"context_menu",
125126
"user_context_menu",
126127
"message_context_menu",
128+
"ConsumeRest",
127129
"ContextMenu",
128130
"ContextMenuContext",
129131
"Converter",

interactions/models/internal/converters.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import re
22
import typing
3-
from typing import Any, Optional, List
3+
from typing import Any, Optional, List, Annotated
44

5-
from interactions.client.const import T, T_co
5+
from interactions.client.const import T, T_co, Sentinel
66
from interactions.client.errors import BadArgument
77
from interactions.client.errors import Forbidden, HTTPException
88
from interactions.models.discord.channel import (
@@ -66,6 +66,7 @@
6666
"CustomEmojiConverter",
6767
"MessageConverter",
6868
"Greedy",
69+
"ConsumeRest",
6970
"MODEL_TO_CONVERTER",
7071
)
7172

@@ -572,6 +573,15 @@ class Greedy(List[T]):
572573
"""A special marker class to mark an argument in a prefixed command to repeatedly convert until it fails to convert an argument."""
573574

574575

576+
class ConsumeRestMarker(Sentinel):
577+
pass
578+
579+
580+
CONSUME_REST_MARKER = ConsumeRestMarker()
581+
582+
ConsumeRest = Annotated[T, CONSUME_REST_MARKER]
583+
"""A special marker type alias to mark an argument in a prefixed command to consume the rest of the arguments."""
584+
575585
MODEL_TO_CONVERTER: dict[type, type[Converter]] = {
576586
SnowflakeObject: SnowflakeConverter,
577587
BaseChannel: BaseChannelConverter,

0 commit comments

Comments
 (0)