[commands] Add commands.Greedy converter and documentation.
This allows for greedy "consume until you can't" behaviour similar to typing.Optional but for lists.
This commit is contained in:
parent
00a445310b
commit
814b03f5a8
@ -34,7 +34,7 @@ __all__ = ['Converter', 'MemberConverter', 'UserConverter',
|
|||||||
'TextChannelConverter', 'InviteConverter', 'RoleConverter',
|
'TextChannelConverter', 'InviteConverter', 'RoleConverter',
|
||||||
'GameConverter', 'ColourConverter', 'VoiceChannelConverter',
|
'GameConverter', 'ColourConverter', 'VoiceChannelConverter',
|
||||||
'EmojiConverter', 'PartialEmojiConverter', 'CategoryChannelConverter',
|
'EmojiConverter', 'PartialEmojiConverter', 'CategoryChannelConverter',
|
||||||
'IDConverter', 'clean_content']
|
'IDConverter', 'clean_content', 'Greedy']
|
||||||
|
|
||||||
def _get_from_guilds(bot, getter, argument):
|
def _get_from_guilds(bot, getter, argument):
|
||||||
result = None
|
result = None
|
||||||
@ -483,3 +483,26 @@ class clean_content(Converter):
|
|||||||
|
|
||||||
# Completely ensure no mentions escape:
|
# Completely ensure no mentions escape:
|
||||||
return re.sub(r'@(everyone|here|[!&]?[0-9]{17,21})', '@\u200b\\1', result)
|
return re.sub(r'@(everyone|here|[!&]?[0-9]{17,21})', '@\u200b\\1', result)
|
||||||
|
|
||||||
|
class _Greedy:
|
||||||
|
__slots__ = ('converter',)
|
||||||
|
|
||||||
|
def __init__(self, *, converter=None):
|
||||||
|
self.converter = converter
|
||||||
|
|
||||||
|
def __getitem__(self, params):
|
||||||
|
if not isinstance(params, tuple):
|
||||||
|
params = (params,)
|
||||||
|
if len(params) != 1:
|
||||||
|
raise TypeError('Greedy[...] only takes a single argument')
|
||||||
|
converter = params[0]
|
||||||
|
|
||||||
|
if not inspect.isclass(converter):
|
||||||
|
raise TypeError('Greedy[...] expects a type.')
|
||||||
|
|
||||||
|
if converter is str or converter is type(None) or converter is _Greedy:
|
||||||
|
raise TypeError('Greedy[%s] is invalid.' % converter.__name__)
|
||||||
|
|
||||||
|
return self.__class__(converter=converter)
|
||||||
|
|
||||||
|
Greedy = _Greedy()
|
||||||
|
@ -199,7 +199,11 @@ class Command:
|
|||||||
# be replaced with the real value for the converters to work later on
|
# be replaced with the real value for the converters to work later on
|
||||||
for key, value in self.params.items():
|
for key, value in self.params.items():
|
||||||
if isinstance(value.annotation, str):
|
if isinstance(value.annotation, str):
|
||||||
self.params[key] = value.replace(annotation=eval(value.annotation, function.__globals__))
|
self.params[key] = value = value.replace(annotation=eval(value.annotation, function.__globals__))
|
||||||
|
|
||||||
|
# fail early for when someone passes an unparameterized Greedy type
|
||||||
|
if value.annotation is converters.Greedy:
|
||||||
|
raise TypeError('Unparameterized Greedy[...] is disallowed in signature.')
|
||||||
|
|
||||||
async def dispatch_error(self, ctx, error):
|
async def dispatch_error(self, ctx, error):
|
||||||
ctx.command_failed = True
|
ctx.command_failed = True
|
||||||
@ -318,6 +322,19 @@ class Command:
|
|||||||
view = ctx.view
|
view = ctx.view
|
||||||
view.skip_ws()
|
view.skip_ws()
|
||||||
|
|
||||||
|
# The greedy converter is simple -- it keeps going until it fails in which case,
|
||||||
|
# it undos the view ready for the next parameter to use instead
|
||||||
|
if type(converter) is converters._Greedy:
|
||||||
|
if param.kind == param.POSITIONAL_OR_KEYWORD:
|
||||||
|
return await self._transform_greedy_pos(ctx, param, required, converter.converter)
|
||||||
|
elif param.kind == param.VAR_POSITIONAL:
|
||||||
|
return await self._transform_greedy_var_pos(ctx, param, converter.converter)
|
||||||
|
else:
|
||||||
|
# if we're here, then it's a KEYWORD_ONLY param type
|
||||||
|
# since this is mostly useless, we'll helpfully transform Greedy[X]
|
||||||
|
# into just X and do the parsing that way.
|
||||||
|
converter = converter.converter
|
||||||
|
|
||||||
if view.eof:
|
if view.eof:
|
||||||
if param.kind == param.VAR_POSITIONAL:
|
if param.kind == param.VAR_POSITIONAL:
|
||||||
raise RuntimeError() # break the loop
|
raise RuntimeError() # break the loop
|
||||||
@ -334,6 +351,43 @@ class Command:
|
|||||||
|
|
||||||
return (await self.do_conversion(ctx, converter, argument, param))
|
return (await self.do_conversion(ctx, converter, argument, param))
|
||||||
|
|
||||||
|
async def _transform_greedy_pos(self, ctx, param, required, converter):
|
||||||
|
view = ctx.view
|
||||||
|
result = []
|
||||||
|
while not view.eof:
|
||||||
|
# for use with a manual undo
|
||||||
|
previous = view.index
|
||||||
|
|
||||||
|
# parsing errors get propagated
|
||||||
|
view.skip_ws()
|
||||||
|
argument = quoted_word(view)
|
||||||
|
try:
|
||||||
|
value = await self.do_conversion(ctx, converter, argument, param)
|
||||||
|
except CommandError as e:
|
||||||
|
if not result:
|
||||||
|
if required:
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
view.index = previous
|
||||||
|
return param.default
|
||||||
|
view.index = previous
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
result.append(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _transform_greedy_var_pos(self, ctx, param, converter):
|
||||||
|
view = ctx.view
|
||||||
|
previous = view.index
|
||||||
|
argument = quoted_word(view)
|
||||||
|
try:
|
||||||
|
value = await self.do_conversion(ctx, converter, argument, param)
|
||||||
|
except CommandError:
|
||||||
|
view.index = previous
|
||||||
|
raise RuntimeError() from None # break loop
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def clean_params(self):
|
def clean_params(self):
|
||||||
"""Retrieves the parameter OrderedDict without the context or self parameters.
|
"""Retrieves the parameter OrderedDict without the context or self parameters.
|
||||||
|
@ -179,6 +179,28 @@ Converters
|
|||||||
.. autoclass:: discord.ext.commands.clean_content
|
.. autoclass:: discord.ext.commands.clean_content
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
.. class:: Greedy
|
||||||
|
|
||||||
|
A special converter that greedily consumes arguments until it can't.
|
||||||
|
As a consequence of this behaviour, most input errors are silently discarded,
|
||||||
|
since it is used as an indicator of when to stop parsing.
|
||||||
|
|
||||||
|
When a parser error is met the greedy converter stops converting, it undos the
|
||||||
|
internal string parsing routine, and continues parsing regularly.
|
||||||
|
|
||||||
|
For example, in the following code:
|
||||||
|
|
||||||
|
.. code-block:: python3
|
||||||
|
|
||||||
|
@commands.command()
|
||||||
|
async def test(ctx, numbers: Greedy[int], reason: str):
|
||||||
|
await ctx.send("numbers: {}, reason: {}".format(numbers, reason))
|
||||||
|
|
||||||
|
An invocation of ``[p]test 1 2 3 4 5 6 hello`` would pass ``numbers`` with
|
||||||
|
``[1, 2, 3, 4, 5, 6]`` and ``reason`` with ``hello``\.
|
||||||
|
|
||||||
|
For more information, check :ref:`ext_commands_special_converters`.
|
||||||
|
|
||||||
.. _ext_commands_api_errors:
|
.. _ext_commands_api_errors:
|
||||||
|
|
||||||
Errors
|
Errors
|
||||||
|
@ -417,6 +417,122 @@ This can get tedious, so an inline advanced converter is possible through a ``cl
|
|||||||
else:
|
else:
|
||||||
await ctx.send("Hm you're not so new.")
|
await ctx.send("Hm you're not so new.")
|
||||||
|
|
||||||
|
.. _ext_commands_special_converters:
|
||||||
|
|
||||||
|
Special Converters
|
||||||
|
++++++++++++++++++++
|
||||||
|
|
||||||
|
The command extension also has support for certain converters to allow for more advanced and intricate use cases that go
|
||||||
|
beyond the generic linear parsing. These converters allow you to introduce some more relaxed and dynamic grammar to your
|
||||||
|
commands in an easy to use manner.
|
||||||
|
|
||||||
|
typing.Union
|
||||||
|
^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
A :class:`typing.Union` is a special type hint that allows for the command to take in any of the specific types instead of
|
||||||
|
a singular type. For example, given the following:
|
||||||
|
|
||||||
|
.. code-block:: python3
|
||||||
|
|
||||||
|
import typing
|
||||||
|
|
||||||
|
@bot.command()
|
||||||
|
async def union(ctx, what: typing.Union[discord.TextChannel, discord.Member]):
|
||||||
|
await ctx.send(what)
|
||||||
|
|
||||||
|
|
||||||
|
The ``what`` parameter would either take a :class:`discord.TextChannel` converter or a :class:`discord.Member` converter.
|
||||||
|
The way this works is through a left-to-right order. It first attempts to convert the input to a
|
||||||
|
:class:`discord.TextChannel`, and if it fails it tries to convert it to a :class:`discord.Member`. If all converters fail,
|
||||||
|
then a special error is raised, :exc:`~ext.commands.BadUnionArgument`.
|
||||||
|
|
||||||
|
Note that any valid converter discussed above can be passed in to the argument list of a :class:`typing.Union`.
|
||||||
|
|
||||||
|
typing.Optional
|
||||||
|
^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
A :class:`typing.Optional` is a special type hint that allows for "back-referencing" behaviour. If the converter fails to
|
||||||
|
parse into the specified type, the parser will skip the parameter and then either ``None`` or the specified default will be
|
||||||
|
passed into the parameter instead. The parser will then continue on to the next parameters and converters, if any.
|
||||||
|
|
||||||
|
Consider the following example:
|
||||||
|
|
||||||
|
.. code-block:: python3
|
||||||
|
|
||||||
|
import typing
|
||||||
|
|
||||||
|
@bot.command()
|
||||||
|
async def bottles(ctx, amount: typing.Optional[int] = 99, *, liquid="beer"):
|
||||||
|
await ctx.send('{} bottles of {} on the wall!'.format(amount, liquid))
|
||||||
|
|
||||||
|
|
||||||
|
.. image:: /images/commands/optional1.png
|
||||||
|
|
||||||
|
In this example, since the argument could not be converted into an ``int``, the default of ``99`` is passed and the parser
|
||||||
|
resumes handling, which in this case would be to pass it into the ``liquid`` parameter.
|
||||||
|
|
||||||
|
Greedy
|
||||||
|
^^^^^^^^
|
||||||
|
|
||||||
|
The :class:`~ext.commands.Greedy` converter is a generalisation of the :class:`typing.Optional` converter, except applied
|
||||||
|
to a list of arguments. In simple terms, this means that it tries to convert as much as it can until it can't convert
|
||||||
|
any further.
|
||||||
|
|
||||||
|
Consider the following example:
|
||||||
|
|
||||||
|
.. code-block:: python3
|
||||||
|
|
||||||
|
@bot.command()
|
||||||
|
async def slap(ctx, members: commands.Greedy[discord.Member], *, reason='no reason'):
|
||||||
|
slapped = ", ".join(x.name for x in members)
|
||||||
|
await ctx.send('{} just got slapped for {}'.format(slapped, reason))
|
||||||
|
|
||||||
|
When invoked, it allows for any number of members to be passed in:
|
||||||
|
|
||||||
|
.. image:: /images/commands/greedy1.png
|
||||||
|
|
||||||
|
The type passed when using this converter depends on the parameter type that it is being attached to:
|
||||||
|
|
||||||
|
- Positional parameter types will receive either the default parameter or a :class:`list` of the converted values.
|
||||||
|
- Variable parameter types will be a :class:`tuple` as usual.
|
||||||
|
- Keyword-only parameter types will be the same as if :class:`~ext.commands.Greedy` was not passed at all.
|
||||||
|
|
||||||
|
:class:`~ext.commands.Greedy` parameters can also be made optional by specifying an optional value.
|
||||||
|
|
||||||
|
When mixed with the :class:`typing.Optional` converter you can provide simple and expressive command invocation syntaxes:
|
||||||
|
|
||||||
|
.. command-block:: python3
|
||||||
|
|
||||||
|
import typing
|
||||||
|
|
||||||
|
@bot.command()
|
||||||
|
async def ban(ctx, members: commands.Greedy[discord.Member],
|
||||||
|
delete_days: typing.Optional[int] = 0, *,
|
||||||
|
reason: str):
|
||||||
|
"""Mass bans members with an optional delete_days parameter"""
|
||||||
|
for member in members:
|
||||||
|
await member.ban(delete_message_days=delete_days, reason=reason)
|
||||||
|
|
||||||
|
|
||||||
|
This command can be invoked any of the following ways:
|
||||||
|
|
||||||
|
.. code-block:: none
|
||||||
|
|
||||||
|
$ban @Member @Member2 spam bot
|
||||||
|
$ban @Member @Member2 7 spam bot
|
||||||
|
$ban @Member spam
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
The usage of :class:`~ext.commands.Greedy` and :class:`typing.Optional` are powerful and useful, however as a
|
||||||
|
price, they open you up to some parsing ambiguities that might surprise some people.
|
||||||
|
|
||||||
|
For example, a signature expecting a :class:`typing.Union` of a :class:`discord.Member` followed by a
|
||||||
|
:class:`int` could catch a member named after a number due to the different ways a
|
||||||
|
:class:`~ext.commands.MemberConverter` decides to fetch members. You should take care to not introduce
|
||||||
|
unintended parsing ambiguities in your code. One technique would be to clamp down the expected syntaxes
|
||||||
|
allowed through custom converters or reordering the parameters to minimise clashes.
|
||||||
|
|
||||||
.. _ext_commands_error_handler:
|
.. _ext_commands_error_handler:
|
||||||
|
|
||||||
Error Handling
|
Error Handling
|
||||||
|
Loading…
x
Reference in New Issue
Block a user