diff --git a/discord/ext/commands/flags.py b/discord/ext/commands/flags.py index dee00fa7e..8afd29a3d 100644 --- a/discord/ext/commands/flags.py +++ b/discord/ext/commands/flags.py @@ -79,6 +79,10 @@ class Flag: description: :class:`str` The description of the flag. Shown for hybrid commands when they're used as application commands. + positional: :class:`bool` + Whether the flag is positional or not. There can only be one positional flag. + + .. versionadded:: 2.4 """ name: str = MISSING @@ -89,6 +93,7 @@ class Flag: max_args: int = MISSING override: bool = MISSING description: str = MISSING + positional: bool = MISSING cast_to_dict: bool = False @property @@ -109,6 +114,7 @@ def flag( override: bool = MISSING, converter: Any = MISSING, description: str = MISSING, + positional: bool = MISSING, ) -> Any: """Override default functionality and parameters of the underlying :class:`FlagConverter` class attributes. @@ -136,6 +142,10 @@ def flag( description: :class:`str` The description of the flag. Shown for hybrid commands when they're used as application commands. + positional: :class:`bool` + Whether the flag is positional or not. There can only be one positional flag. + + .. versionadded:: 2.4 """ return Flag( name=name, @@ -145,6 +155,7 @@ def flag( override=override, annotation=converter, description=description, + positional=positional, ) @@ -171,6 +182,7 @@ def get_flags(namespace: Dict[str, Any], globals: Dict[str, Any], locals: Dict[s flags: Dict[str, Flag] = {} cache: Dict[str, Any] = {} names: Set[str] = set() + positional: Optional[Flag] = None for name, annotation in annotations.items(): flag = namespace.pop(name, MISSING) if isinstance(flag, Flag): @@ -183,6 +195,11 @@ def get_flags(namespace: Dict[str, Any], globals: Dict[str, Any], locals: Dict[s if flag.name is MISSING: flag.name = name + if flag.positional: + if positional is not None: + raise TypeError(f"{flag.name!r} positional flag conflicts with {positional.name!r} flag.") + positional = flag + annotation = flag.annotation = resolve_annotation(flag.annotation, globals, locals, cache) if flag.default is MISSING and hasattr(annotation, '__commands_is_flag__') and annotation._can_be_constructible(): @@ -270,6 +287,7 @@ class FlagsMeta(type): __commands_flag_case_insensitive__: bool __commands_flag_delimiter__: str __commands_flag_prefix__: str + __commands_flag_positional__: Optional[Flag] def __new__( cls, @@ -324,9 +342,13 @@ class FlagsMeta(type): delimiter = attrs.setdefault('__commands_flag_delimiter__', ':') prefix = attrs.setdefault('__commands_flag_prefix__', '') + positional: Optional[Flag] = None for flag_name, flag in get_flags(attrs, global_ns, local_ns).items(): flags[flag_name] = flag aliases.update({alias_name: flag_name for alias_name in flag.aliases}) + if flag.positional: + positional = flag + attrs['__commands_flag_positional__'] = positional forbidden = set(delimiter).union(prefix) for flag_name in flags: @@ -500,10 +522,25 @@ class FlagConverter(metaclass=FlagsMeta): result: Dict[str, List[str]] = {} flags = cls.__commands_flags__ aliases = cls.__commands_flag_aliases__ + positional_flag = cls.__commands_flag_positional__ last_position = 0 last_flag: Optional[Flag] = None case_insensitive = cls.__commands_flag_case_insensitive__ + + if positional_flag is not None: + match = cls.__commands_flag_regex__.search(argument) + if match is not None: + begin, end = match.span(0) + value = argument[:begin].strip() + else: + value = argument.strip() + last_position = len(argument) + + if value: + name = positional_flag.name.casefold() if case_insensitive else positional_flag.name + result[name] = [value] + for match in cls.__commands_flag_regex__.finditer(argument): begin, end = match.span(0) key = match.group('flag') diff --git a/docs/ext/commands/commands.rst b/docs/ext/commands/commands.rst index 02a9ae670..52e57ff4d 100644 --- a/docs/ext/commands/commands.rst +++ b/docs/ext/commands/commands.rst @@ -778,6 +778,19 @@ This tells the parser that the ``members`` attribute is mapped to a flag named ` 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. +A positional flag can be defined by setting the :attr:`~ext.commands.Flag.positional` attribute to ``True``. This +tells the parser that the content provided before the parsing occurs is part of the flag. This is useful for commands that +require a parameter to be used first and the flags are optional, such as the following: + +.. code-block:: python3 + + class BanFlags(commands.FlagConverter): + members: List[discord.Member] = commands.flag(name='member', positional=True, default=lambda ctx: []) + reason: Optional[str] = None + +.. note:: + Only one positional flag is allowed in a flag converter. + 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 @@ -796,12 +809,17 @@ In order to customise the flag syntax we also have a few options that can be pas topic: Optional[str] nsfw: Optional[bool] slowmode: Optional[int] + + # Hello there --bold True + class Greeting(commands.FlagConverter): + text: str = commands.flag(positional=True) + bold: bool = False .. note:: Despite the similarities in these examples to command like arguments, the syntax and parser is not a command line parser. The syntax is mainly inspired by Discord's search bar input and as a result - all flags need a corresponding value. + all flags need a corresponding value unless part of a positional flag. Flag converters will only raise :exc:`~ext.commands.FlagError` derived exceptions. If an error is raised while converting a flag, :exc:`~ext.commands.BadFlagArgument` is raised instead and the original exception