From 814b03f5a8a6faa33d80495691f1e1cbdce40ce2 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 24 Sep 2018 03:56:32 -0400 Subject: [PATCH] [commands] Add commands.Greedy converter and documentation. This allows for greedy "consume until you can't" behaviour similar to typing.Optional but for lists. --- discord/ext/commands/converter.py | 25 ++++++- discord/ext/commands/core.py | 56 ++++++++++++++- docs/ext/commands/api.rst | 22 ++++++ docs/ext/commands/commands.rst | 116 ++++++++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 2 deletions(-) diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index 880876a43..461abee70 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -34,7 +34,7 @@ __all__ = ['Converter', 'MemberConverter', 'UserConverter', 'TextChannelConverter', 'InviteConverter', 'RoleConverter', 'GameConverter', 'ColourConverter', 'VoiceChannelConverter', 'EmojiConverter', 'PartialEmojiConverter', 'CategoryChannelConverter', - 'IDConverter', 'clean_content'] + 'IDConverter', 'clean_content', 'Greedy'] def _get_from_guilds(bot, getter, argument): result = None @@ -483,3 +483,26 @@ class clean_content(Converter): # Completely ensure no mentions escape: 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() diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index c64aeb9a1..0aa796451 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -199,7 +199,11 @@ class Command: # be replaced with the real value for the converters to work later on for key, value in self.params.items(): 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): ctx.command_failed = True @@ -318,6 +322,19 @@ class Command: view = ctx.view 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 param.kind == param.VAR_POSITIONAL: raise RuntimeError() # break the loop @@ -334,6 +351,43 @@ class Command: 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 def clean_params(self): """Retrieves the parameter OrderedDict without the context or self parameters. diff --git a/docs/ext/commands/api.rst b/docs/ext/commands/api.rst index ddbd6325c..0badeb80a 100644 --- a/docs/ext/commands/api.rst +++ b/docs/ext/commands/api.rst @@ -179,6 +179,28 @@ Converters .. autoclass:: discord.ext.commands.clean_content :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: Errors diff --git a/docs/ext/commands/commands.rst b/docs/ext/commands/commands.rst index 336ed5de7..36c9de98c 100644 --- a/docs/ext/commands/commands.rst +++ b/docs/ext/commands/commands.rst @@ -417,6 +417,122 @@ This can get tedious, so an inline advanced converter is possible through a ``cl else: 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: Error Handling