aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRapptz <[email protected]>2021-04-19 10:25:08 -0400
committerRapptz <[email protected]>2021-04-19 10:25:08 -0400
commitddb71e2aedf081c3d261a992c30b345f3e38baf5 (patch)
treebd622f28198058756629f4def419f26a557822e0
parentRemove lingering User.avatar documentation (diff)
downloaddiscord.py-ddb71e2aedf081c3d261a992c30b345f3e38baf5.tar.xz
discord.py-ddb71e2aedf081c3d261a992c30b345f3e38baf5.zip
[commands] Initial support for FlagConverter
The name is currently pending and there's no command.signature hook for it yet since this requires bikeshedding.
-rw-r--r--discord/ext/commands/__init__.py1
-rw-r--r--discord/ext/commands/errors.py77
-rw-r--r--discord/ext/commands/flags.py530
-rw-r--r--docs/ext/commands/api.rst27
-rw-r--r--docs/ext/commands/commands.rst151
-rw-r--r--docs/images/commands/flags1.pngbin0 -> 26037 bytes
-rw-r--r--docs/images/commands/flags2.pngbin0 -> 28823 bytes
-rw-r--r--docs/images/commands/flags3.pngbin0 -> 27449 bytes
8 files changed, 786 insertions, 0 deletions
diff --git a/discord/ext/commands/__init__.py b/discord/ext/commands/__init__.py
index cb866e9f..3da57d80 100644
--- a/discord/ext/commands/__init__.py
+++ b/discord/ext/commands/__init__.py
@@ -16,3 +16,4 @@ from .help import *
from .converter import *
from .cooldowns import *
from .cog import *
+from .flags import *
diff --git a/discord/ext/commands/errors.py b/discord/ext/commands/errors.py
index bfbe55fc..7f6948ce 100644
--- a/discord/ext/commands/errors.py
+++ b/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')
diff --git a/discord/ext/commands/flags.py b/discord/ext/commands/flags.py
new file mode 100644
index 00000000..dc632721
--- /dev/null
+++ b/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
diff --git a/docs/ext/commands/api.rst b/docs/ext/commands/api.rst
index b8ef64cf..2ccf7936 100644
--- a/docs/ext/commands/api.rst
+++ b/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`
diff --git a/docs/ext/commands/commands.rst b/docs/ext/commands/commands.rst
index e473a018..5c24f4c6 100644
--- a/docs/ext/commands/commands.rst
+++ b/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
diff --git a/docs/images/commands/flags1.png b/docs/images/commands/flags1.png
new file mode 100644
index 00000000..af2d6175
--- /dev/null
+++ b/docs/images/commands/flags1.png
Binary files differ
diff --git a/docs/images/commands/flags2.png b/docs/images/commands/flags2.png
new file mode 100644
index 00000000..a7d984d6
--- /dev/null
+++ b/docs/images/commands/flags2.png
Binary files differ
diff --git a/docs/images/commands/flags3.png b/docs/images/commands/flags3.png
new file mode 100644
index 00000000..6978366e
--- /dev/null
+++ b/docs/images/commands/flags3.png
Binary files differ