Skip to content

feat: implement positional flags #2443

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ These changes are available on the `master` branch, but have not yet been releas
([#2714](https://github.com/Pycord-Development/pycord/pull/2714))
- Added the ability to pass a `datetime.time` object to `format_dt`
([#2747](https://github.com/Pycord-Development/pycord/pull/2747))
- Added `positional` argument to `commands.Flag`.
([#2443](https://github.com/Pycord-Development/pycord/pull/2443))

### Fixed

Expand Down
41 changes: 41 additions & 0 deletions discord/ext/commands/flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ class Flag:
max_args: :class:`int`
The maximum number of arguments the flag can accept.
A negative value indicates an unlimited amount of arguments.
positional: :class:`bool`
Whether the flag is positional.
A :class:`FlagConverter` can only handle one positional flag.
override: :class:`bool`
Whether multiple given values overrides the previous value.
"""
Expand All @@ -92,6 +95,7 @@ class Flag:
annotation: Any = _MISSING
default: Any = _MISSING
max_args: int = _MISSING
positional: bool = _MISSING
override: bool = _MISSING
cast_to_dict: bool = False

Expand All @@ -111,6 +115,7 @@ def flag(
default: Any = MISSING,
max_args: int = MISSING,
override: bool = MISSING,
positional: bool = MISSING,
) -> Any:
"""Override default functionality and parameters of the underlying :class:`FlagConverter`
class attributes.
Expand All @@ -132,13 +137,16 @@ class attributes.
override: :class:`bool`
Whether multiple given values overrides the previous value. The default
value depends on the annotation given.
positional: :class:`bool`
Whether the flag is positional or not. There can only be one positional flag.
"""
return Flag(
name=name,
aliases=aliases,
default=default,
max_args=max_args,
override=override,
positional=positional,
)


Expand All @@ -165,6 +173,7 @@ def get_flags(
flags: dict[str, Flag] = {}
cache: dict[str, Any] = {}
names: set[str] = set()
positional: Flag | None = None
for name, annotation in annotations.items():
flag = namespace.pop(name, MISSING)
if isinstance(flag, Flag):
Expand All @@ -176,6 +185,14 @@ def get_flags(
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
)
Expand Down Expand Up @@ -277,6 +294,7 @@ class FlagsMeta(type):
__commands_flag_case_insensitive__: bool
__commands_flag_delimiter__: str
__commands_flag_prefix__: str
__commands_flag_positional__: Flag | None

def __new__(
cls: type[type],
Expand Down Expand Up @@ -337,9 +355,13 @@ def __new__(
delimiter = attrs.setdefault("__commands_flag_delimiter__", ":")
prefix = attrs.setdefault("__commands_flag_prefix__", "")

positional_flag: Flag | None = None
for flag_name, flag in get_flags(attrs, global_ns, local_ns).items():
flags[flag_name] = flag
if flag.positional:
positional_flag = flag
aliases.update({alias_name: flag_name for alias_name in flag.aliases})
attrs["__commands_flag_positional__"] = positional_flag

forbidden = set(delimiter).union(prefix)
for flag_name in flags:
Expand Down Expand Up @@ -539,10 +561,29 @@ def parse_flags(cls, argument: str) -> dict[str, list[str]]:
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: Flag | None = 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")
Expand Down
21 changes: 20 additions & 1 deletion docs/ext/commands/commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,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.

Flags can also be positional. This means that the flag does not require a corresponding
value to be passed in by the user. This is useful for flags that are either optional or have a default value.
For example, in the following code:

.. code-block:: python3

class BanFlags(commands.FlagConverter):
members: List[discord.Member] = commands.flag(name='member', positional=True)
reason: str = commands.flag(default='no reason')
days: int = commands.flag(default=1)

The ``members`` flag is marked as positional, meaning that the user can invoke the command without explicitly specifying the flag.

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
Expand All @@ -675,11 +688,17 @@ In order to customise the flag syntax we also have a few options that can be pas
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 a positional flag is provided.

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
Expand Down