Browse Source

[commands] Initial support for FlagConverter

The name is currently pending and there's no command.signature hook
for it yet since this requires bikeshedding.
pull/6758/head
Rapptz 4 years ago
parent
commit
ddb71e2aed
  1. 1
      discord/ext/commands/__init__.py
  2. 77
      discord/ext/commands/errors.py
  3. 530
      discord/ext/commands/flags.py
  4. 27
      docs/ext/commands/api.rst
  5. 151
      docs/ext/commands/commands.rst
  6. BIN
      docs/images/commands/flags1.png
  7. BIN
      docs/images/commands/flags2.png
  8. BIN
      docs/images/commands/flags3.png

1
discord/ext/commands/__init__.py

@ -16,3 +16,4 @@ from .help import *
from .converter import *
from .cooldowns import *
from .cog import *
from .flags import *

77
discord/ext/commands/errors.py

@ -75,6 +75,10 @@ __all__ = (
'ExtensionFailed',
'ExtensionNotFound',
'CommandRegistrationError',
'BadFlagArgument',
'MissingFlagArgument',
'TooManyFlags',
'MissingRequiredFlag',
)
class CommandError(DiscordException):
@ -855,3 +859,76 @@ class CommandRegistrationError(ClientException):
self.alias_conflict = alias_conflict
type_ = 'alias' if alias_conflict else 'command'
super().__init__(f'The {type_} {name} is already an existing command or alias.')
class FlagError(BadArgument):
"""The base exception type for all flag parsing related errors.
This inherits from :exc:`BadArgument`.
.. versionadded:: 2.0
"""
pass
class TooManyFlags(FlagError):
"""An exception raised when a flag has received too many values.
This inherits from :exc:`FlagError`.
.. versionadded:: 2.0
Attributes
------------
flag: :class:`~discord.ext.commands.Flag`
The flag that received too many values.
values: List[:class:`str`]
The values that were passed.
"""
def __init__(self, flag, values):
self.flag = flag
self.values = values
super().__init__(f'Too many flag values, expected {flag.max_args} but received {len(values)}.')
class BadFlagArgument(FlagError):
"""An exception raised when a flag failed to convert a value.
"""
def __init__(self, flag):
self.flag = flag
try:
name = flag.annotation.__name__
except AttributeError:
name = flag.annotation.__class__.__name__
super().__init__(f'Could not convert to {name!r} for flag {flag.name!r}')
class MissingRequiredFlag(FlagError):
"""An exception raised when a required flag was not given.
This inherits from :exc:`FlagError`
.. versionadded:: 2.0
Attributes
-----------
flag: :class:`~discord.ext.commands.Flag`
The required flag that was not found.
"""
def __init__(self, flag):
self.flag = flag
super().__init__(f'Flag {flag.name!r} is required and missing')
class MissingFlagArgument(FlagError):
"""An exception raised when a flag did not get a value.
This inherits from :exc:`FlagError`
.. versionadded:: 2.0
Attributes
-----------
flag: :class:`~discord.ext.commands.Flag`
The flag that did not get a value.
"""
def __init__(self, flag):
self.flag = flag
super().__init__(f'Flag {flag.name!r} does not have an argument')

530
discord/ext/commands/flags.py

@ -0,0 +1,530 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from .errors import (
BadFlagArgument,
CommandError,
MissingFlagArgument,
TooManyFlags,
MissingRequiredFlag,
)
from .core import resolve_annotation
from .view import StringView
from .converter import run_converters
from discord.utils import maybe_coroutine
from dataclasses import dataclass
from typing import (
Dict,
Optional,
Pattern,
Set,
TYPE_CHECKING,
Tuple,
List,
Any,
Type,
TypeVar,
Union,
)
import inspect
import sys
import re
__all__ = (
'Flag',
'flag',
'FlagConverter',
)
if TYPE_CHECKING:
from .context import Context
class _MissingSentinel:
def __repr__(self):
return 'MISSING'
MISSING: Any = _MissingSentinel()
@dataclass
class Flag:
"""Represents a flag parameter for :class:`FlagConverter`.
The :func:`~discord.ext.commands.flag` function helps
create these flag objects, but it is not necessary to
do so. These cannot be constructed manually.
Attributes
------------
name: :class:`str`
The name of the flag.
attribute: :class:`str`
The attribute in the class that corresponds to this flag.
default: Any
The default value of the flag, if available.
annotation: Any
The underlying evaluated annotation of the flag.
max_args: :class:`int`
The maximum number of arguments the flag can accept.
A negative value indicates an unlimited amount of arguments.
override: :class:`bool`
Whether multiple given values overrides the previous value.
"""
name: str = MISSING
attribute: str = MISSING
annotation: Any = MISSING
default: Any = MISSING
max_args: int = MISSING
override: bool = MISSING
cast_to_dict: bool = False
@property
def required(self) -> bool:
""":class:`bool`: Whether the flag is required.
A required flag has no default value.
"""
return self.default is MISSING
def flag(
*,
name: str = MISSING,
default: Any = MISSING,
max_args: int = MISSING,
override: bool = MISSING,
) -> Any:
"""Override default functionality and parameters of the underlying :class:`FlagConverter`
class attributes.
Parameters
------------
name: :class:`str`
The flag name. If not given, defaults to the attribute name.
default: Any
The default parameter. This could be either a value or a callable that takes
:class:`Context` as its sole parameter. If not given then it defaults to
the default value given to the attribute.
max_args: :class:`int`
The maximum number of arguments the flag can accept.
A negative value indicates an unlimited amount of arguments.
The default value depends on the annotation given.
override: :class:`bool`
Whether multiple given values overrides the previous value. The default
value depends on the annotation given.
"""
return Flag(name=name, default=default, max_args=max_args, override=override)
def validate_flag_name(name: str, forbidden: Set[str]):
if not name:
raise ValueError('flag names should not be empty')
for ch in name:
if ch.isspace():
raise ValueError(f'flag name {name!r} cannot have spaces')
if ch == '\\':
raise ValueError(f'flag name {name!r} cannot have backslashes')
if ch in forbidden:
raise ValueError(f'flag name {name!r} cannot have any of {forbidden!r} within them')
def get_flags(namespace: Dict[str, Any], globals: Dict[str, Any], locals: Dict[str, Any]) -> Dict[str, Flag]:
annotations = namespace.get('__annotations__', {})
flags: Dict[str, Flag] = {}
cache: Dict[str, Any] = {}
for name, annotation in annotations.items():
flag = namespace.pop(name, MISSING)
if isinstance(flag, Flag):
flag.annotation = annotation
else:
flag = Flag(name=name, annotation=annotation, default=flag)
flag.attribute = name
if flag.name is MISSING:
flag.name = name
annotation = flag.annotation = resolve_annotation(flag.annotation, globals, locals, cache)
# Add sensible defaults based off of the type annotation
# <type> -> (max_args=1)
# List[str] -> (max_args=-1)
# Tuple[int, ...] -> (max_args=1)
# Dict[K, V] -> (max_args=-1, override=True)
# Optional[str] -> (default=None, max_args=1)
try:
origin = annotation.__origin__
except AttributeError:
# A regular type hint
if flag.max_args is MISSING:
flag.max_args = 1
else:
if origin is Union and annotation.__args__[-1] is type(None):
# typing.Optional
if flag.max_args is MISSING:
flag.max_args = 1
if flag.default is MISSING:
flag.default = None
elif origin is tuple:
# typing.Tuple
# tuple parsing is e.g. `flag: peter 20`
# for Tuple[str, int] would give you flag: ('peter', 20)
if flag.max_args is MISSING:
flag.max_args = 1
elif origin is list:
# typing.List
if flag.max_args is MISSING:
flag.max_args = -1
elif origin is dict:
# typing.Dict[K, V]
# Equivalent to:
# typing.List[typing.Tuple[K, V]]
flag.cast_to_dict = True
if flag.max_args is MISSING:
flag.max_args = -1
if flag.override is MISSING:
flag.override = True
else:
raise TypeError(f'Unsupported typing annotation {annotation!r} for {flag.name!r} flag')
if flag.override is MISSING:
flag.override = False
flags[flag.name] = flag
return flags
class FlagsMeta(type):
if TYPE_CHECKING:
__commands_is_flag__: bool
__commands_flags__: Dict[str, Flag]
__commands_flag_regex__: Pattern[str]
__commands_flag_case_insensitive__: bool
__commands_flag_delimiter__: str
__commands_flag_prefix__: str
def __new__(
cls: Type[type],
name: str,
bases: Tuple[type, ...],
attrs: Dict[str, Any],
*,
case_insensitive: bool = False,
delimiter: str = ':',
prefix: str = '',
):
attrs['__commands_is_flag__'] = True
attrs['__commands_flag_case_insensitive__'] = case_insensitive
attrs['__commands_flag_delimiter__'] = delimiter
attrs['__commands_flag_prefix__'] = prefix
if not prefix and not delimiter:
raise TypeError('Must have either a delimiter or a prefix set')
try:
global_ns = sys.modules[attrs['__module__']].__dict__
except KeyError:
global_ns = {}
frame = inspect.currentframe()
try:
if frame is None:
local_ns = {}
else:
if frame.f_back is None:
local_ns = frame.f_locals
else:
local_ns = frame.f_back.f_locals
finally:
del frame
flags: Dict[str, Flag] = {}
for base in reversed(bases):
if base.__dict__.get('__commands_is_flag__', False):
flags.update(base.__dict__['__commands_flags__'])
flags.update(get_flags(attrs, global_ns, local_ns))
forbidden = set(delimiter).union(prefix)
for flag_name in flags:
validate_flag_name(flag_name, forbidden)
regex_flags = 0
if case_insensitive:
flags = {key.casefold(): value for key, value in flags.items()}
regex_flags = re.IGNORECASE
keys = sorted((re.escape(k) for k in flags), key=lambda t: len(t), reverse=True)
joined = '|'.join(keys)
pattern = re.compile(f'(({re.escape(prefix)})(?P<flag>{joined}){re.escape(delimiter)})', regex_flags)
attrs['__commands_flag_regex__'] = pattern
attrs['__commands_flags__'] = flags
return type.__new__(cls, name, bases, attrs)
async def tuple_convert_all(ctx: Context, argument: str, flag: Flag, converter: Any) -> Tuple[Any, ...]:
view = StringView(argument)
results = []
param: inspect.Parameter = ctx.current_parameter # type: ignore
while not view.eof:
view.skip_ws()
if view.eof:
break
word = view.get_quoted_word()
if word is None:
break
try:
converted = await run_converters(ctx, converter, word, param)
except CommandError:
raise
except Exception as e:
raise BadFlagArgument(flag) from e
else:
results.append(converted)
return tuple(results)
async def tuple_convert_flag(ctx: Context, argument: str, flag: Flag, converters: Any) -> Tuple[Any, ...]:
view = StringView(argument)
results = []
param: inspect.Parameter = ctx.current_parameter # type: ignore
for converter in converters:
view.skip_ws()
if view.eof:
break
word = view.get_quoted_word()
if word is None:
break
try:
converted = await run_converters(ctx, converter, word, param)
except CommandError:
raise
except Exception as e:
raise BadFlagArgument(flag) from e
else:
results.append(converted)
if len(results) != len(converters):
raise BadFlagArgument(flag)
return tuple(results)
async def convert_flag(ctx, argument: str, flag: Flag, annotation: Any = None) -> Any:
param: inspect.Parameter = ctx.current_parameter # type: ignore
annotation = annotation or flag.annotation
try:
origin = annotation.__origin__
except AttributeError:
pass
else:
if origin is tuple:
if annotation.__args__[-1] is Ellipsis:
return await tuple_convert_all(ctx, argument, flag, annotation.__args__[0])
else:
return await tuple_convert_flag(ctx, argument, flag, annotation.__args__)
elif origin is list or origin is Union and annotation.__args__[-1] is type(None):
# typing.List[x] or typing.Optional[x]
annotation = annotation.__args__[0]
return await convert_flag(ctx, argument, flag, annotation)
elif origin is dict:
# typing.Dict[K, V] -> typing.Tuple[K, V]
return await tuple_convert_flag(ctx, argument, flag, annotation.__args__)
try:
return await run_converters(ctx, annotation, argument, param)
except CommandError:
raise
except Exception as e:
raise BadFlagArgument(flag) from e
F = TypeVar('F', bound='FlagConverter')
class FlagConverter(metaclass=FlagsMeta):
"""A converter that allows for a user-friendly flag syntax.
The flags are defined using :pep:`526` type annotations similar
to the :mod:`dataclasses` Python module. For more information on
how this converter works, check the appropriate
:ref:`documentation <ext_commands_flag_converter>`.
.. versionadded:: 2.0
Parameters
-----------
case_insensitive: :class:`bool`
A class parameter to toggle case insensitivity of the flag parsing.
If ``True`` then flags are parsed in a case insensitive manner.
Defaults to ``False``.
prefix: :class:`str`
The prefix that all flags must be prefixed with. By default
there is no prefix.
delimiter: :class:`str`
The delimiter that separates a flag's argument from the flag's name.
By default this is ``:``.
"""
@classmethod
def get_flags(cls) -> Dict[str, Flag]:
"""Dict[:class:`str`, :class:`Flag`]: A mapping of flag name to flag object this converter has."""
return cls.__commands_flags__.copy()
def __repr__(self) -> str:
pairs = ' '.join([f'{flag.attribute}={getattr(self, flag.attribute)!r}' for flag in self.get_flags().values()])
return f'<{self.__class__.__name__} {pairs}>'
@classmethod
def parse_flags(cls, argument: str) -> Dict[str, List[str]]:
result: Dict[str, List[str]] = {}
flags = cls.get_flags()
last_position = 0
last_flag: Optional[Flag] = None
case_insensitive = cls.__commands_flag_case_insensitive__
for match in cls.__commands_flag_regex__.finditer(argument):
begin, end = match.span(0)
key = match.group('flag')
if case_insensitive:
key = key.casefold()
flag = flags.get(key)
if last_position and last_flag is not None:
value = argument[last_position : begin - 1].lstrip()
if not value:
raise MissingFlagArgument(last_flag)
try:
values = result[last_flag.name]
except KeyError:
result[last_flag.name] = [value]
else:
values.append(value)
last_position = end
last_flag = flag
# Add the remaining string to the last available flag
if last_position and last_flag is not None:
value = argument[last_position:].strip()
if not value:
raise MissingFlagArgument(last_flag)
try:
values = result[last_flag.name]
except KeyError:
result[last_flag.name] = [value]
else:
values.append(value)
# Verification of values will come at a later stage
return result
@classmethod
async def convert(cls: Type[F], ctx: Context, argument: str) -> F:
"""|coro|
The method that actually converters an argument to the flag mapping.
Parameters
----------
cls: Type[:class:`FlagConverter`]
The flag converter class.
ctx: :class:`Context`
The invocation context.
argument: :class:`str`
The argument to convert from.
Raises
--------
FlagError
A flag related parsing error.
CommandError
A command related error.
Returns
--------
:class:`FlagConverter`
The flag converter instance with all flags parsed.
"""
arguments = cls.parse_flags(argument)
flags = cls.get_flags()
self: F = cls.__new__(cls)
for name, flag in flags.items():
try:
values = arguments[name]
except KeyError:
if flag.required:
raise MissingRequiredFlag(flag)
else:
if callable(flag.default):
default = await maybe_coroutine(flag.default, ctx)
setattr(self, flag.attribute, default)
else:
setattr(self, flag.attribute, flag.default)
continue
if flag.max_args > 0 and len(values) > flag.max_args:
if flag.override:
values = values[-flag.max_args :]
else:
raise TooManyFlags(flag, values)
# Special case:
if flag.max_args == 1:
value = await convert_flag(ctx, values[0], flag)
setattr(self, flag.attribute, value)
continue
# Another special case, tuple parsing.
# Tuple parsing is basically converting arguments within the flag
# So, given flag: hello 20 as the input and Tuple[str, int] as the type hint
# We would receive ('hello', 20) as the resulting value
# This uses the same whitespace and quoting rules as regular parameters.
values = [await convert_flag(ctx, value, flag) for value in values]
if flag.cast_to_dict:
values = dict(values) # type: ignore
setattr(self, flag.attribute, values)
return self

27
docs/ext/commands/api.rst

@ -331,6 +331,17 @@ Converters
.. autofunction:: discord.ext.commands.run_converters
Flag Converter
~~~~~~~~~~~~~~~
.. autoclass:: discord.ext.commands.FlagConverter
:members:
.. autoclass:: discord.ext.commands.Flag()
:members:
.. autofunction:: discord.ext.commands.flag
.. _ext_commands_api_errors:
Exceptions
@ -456,6 +467,18 @@ Exceptions
.. autoexception:: discord.ext.commands.NSFWChannelRequired
:members:
.. autoexception:: discord.ext.commands.BadFlagArgument
:members:
.. autoexception:: discord.ext.commands.MissingFlagArgument
:members:
.. autoexception:: discord.ext.commands.TooManyFlags
:members:
.. autoexception:: discord.ext.commands.MissingRequiredFlag
:members:
.. autoexception:: discord.ext.commands.ExtensionError
:members:
@ -501,6 +524,10 @@ Exception Hierarchy
- :exc:`~.commands.EmojiNotFound`
- :exc:`~.commands.PartialEmojiConversionFailure`
- :exc:`~.commands.BadBoolArgument`
- :exc:`~.commands.BadFlagArgument`
- :exc:`~.commands.MissingFlagArgument`
- :exc:`~.commands.TooManyFlags`
- :exc:`~.commands.MissingRequiredFlag`
- :exc:`~.commands.BadUnionArgument`
- :exc:`~.commands.ArgumentParsingError`
- :exc:`~.commands.UnexpectedQuoteError`

151
docs/ext/commands/commands.rst

@ -594,6 +594,157 @@ This command can be invoked any of the following ways:
To help aid with some parsing ambiguities, :class:`str`, ``None``, :data:`typing.Optional` and
:class:`~ext.commands.Greedy` are forbidden as parameters for the :class:`~ext.commands.Greedy` converter.
.. _ext_commands_flag_converter:
FlagConverter
++++++++++++++
.. versionadded:: 2.0
A :class:`~ext.commands.FlagConverter` allows the user to specify user-friendly "flags" using :pep:`526` type annotations
or a syntax more reminiscent of the :mod:`py:dataclasses` module.
For example, the following code:
.. code-block:: python3
from discord.ext import commands
import discord
class BanFlags(commands.FlagConverter):
member: discord.Member
reason: str
days: int = 1
@commands.command()
async def ban(ctx, *, flags: BanFlags):
plural = f'{flags.days} days' if flags.days != 1 else f'{flags.days} day'
await ctx.send(f'Banned {flags.member} for {flags.reason!r} (deleted {plural} worth of messages)')
Allows the user to invoke the command using a simple flag-like syntax:
.. image:: /images/commands/flags1.png
Flags use a syntax that allows the user to not require quotes when passing flags. The goal of the flag syntax is to be as
user-friendly as possible. This makes flags a good choice for complicated commands that can have multiple knobs.
**It is recommended to use keyword-only parameters with the flag converter**. This ensures proper parsing and
behaviour with quoting.
The :class:`~ext.commands.FlagConverter` class examines the class to find flags. A flag can either be a
class variable with a type annotation or a class variable that's been assigned the result of the :func:`~ext.commands.flag`
function.
For most use cases, no extra work is required to define flags. However, if customisation is needed to control the flag name
or the default value then the :func:`~ext.commands.flag` function can come in handy:
.. code-block:: python3
from typing import List
class BanFlags(commands.FlagConverter):
members: List[discord.Member] = commands.flag(name='member', default=lambda ctx: [])
This tells the parser that the ``members`` attribute is mapped to a flag named ``member`` and that
the default value is an empty list. For greater customisability, the default can either be a value or a callable
that takes the :class:`~ext.commands.Context` as a sole parameter. This callable can either be a function or a coroutine.
In order to customise the flag syntax we also have a few options that can be passed to the class parameter list:
.. code-block:: python3
# --hello=world syntax
class PosixLikeFlags(commands.FlagConverter, delimiter='=', prefix='--'):
hello: str
# /make food
class WindowsLikeFlags(commands.FlagConverter, prefix='/', delimiter=''):
make: str
# TOPIC: not allowed nsfw: yes Slowmode: 100
class Settings(commands.FlagConverter, case_insentitive=True):
topic: Optional[str]
nsfw: Optional[bool]
slowmode: Optional[int]
The flag converter is similar to regular commands and allows you to use most types of converters
(with the exception of :class:`~ext.commands.Greedy`) as the type annotation. Some extra support is added for specific
annotations as described below.
typing.List
^^^^^^^^^^^^^
If a list is given as a flag annotation it tells the parser that the argument can be passed multiple times.
For example, augmenting the example above:
.. code-block:: python3
from discord.ext import commands
from typing import List
import discord
class BanFlags(commands.FlagConverter):
members: List[discord.Member] = commands.flag(name='member')
reason: str
days: int = 1
@commands.command()
async def ban(ctx, *, flags: BanFlags):
for member in flags.members:
await member.ban(reason=flags.reason, delete_message_days=flags.days)
members = ', '.join(str(member) for member in flags.members)
plural = f'{flags.days} days' if flags.days != 1 else f'{flags.days} day'
await ctx.send(f'Banned {members} for {flags.reason!r} (deleted {plural} worth of messages)')
This is called by repeatedly specifying the flag:
.. image:: /images/commands/flags2.png
typing.Tuple
^^^^^^^^^^^^^
Since the above syntax can be a bit repetitive when specifying a flag many times, the :class:`py:tuple` type annotation
allows for "greedy-like" semantics using a variadic tuple:
.. code-block:: python3
from discord.ext import commands
from typing import Tuple
import discord
class BanFlags(commands.FlagConverter):
members: Tuple[discord.Member, ...]
reason: str
days: int = 1
This allows the previous ``ban`` command to be called like this:
.. image:: /images/commands/flags3.png
The :class:`py:tuple` annotation also allows for parsing of pairs. For example, given the following code:
.. code-block:: python3
# point: 10 11 point: 12 13
class Coordinates(commands.FlagConverter):
point: Tuple[int, int]
.. warning::
Due to potential parsing ambiguities, the parser expects tuple arguments to be quoted
if they require spaces. So if one of the inner types is :class:`str` and the argument requires spaces
then quotes should be used to disambiguate it from the other element of the tuple.
typing.Dict
^^^^^^^^^^^^^
A :class:`dict` annotation is functionally equivalent to ``List[Tuple[K, V]]`` except with the return type
given as a :class:`dict` rather than a :class:`list`.
.. _ext_commands_error_handler:
Error Handling

BIN
docs/images/commands/flags1.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
docs/images/commands/flags2.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
docs/images/commands/flags3.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Loading…
Cancel
Save