From 2a3071712e95c3f75da85affd2f7889b58f37771 Mon Sep 17 00:00:00 2001 From: Zomatree Date: Sat, 30 Oct 2021 15:57:03 +0100 Subject: [PATCH] add proper argument parsing - converters and keyword arguments --- revolt/ext/commands/command.py | 60 ++++++++++++++++++++++++------- revolt/ext/commands/context.py | 24 +++++++++---- revolt/ext/commands/mixin.py | 64 ++++++++++++---------------------- revolt/ext/commands/view.py | 39 +++++++++++++++++++++ 4 files changed, 127 insertions(+), 60 deletions(-) create mode 100755 revolt/ext/commands/view.py diff --git a/revolt/ext/commands/command.py b/revolt/ext/commands/command.py index cc59a2e..d6927d0 100755 --- a/revolt/ext/commands/command.py +++ b/revolt/ext/commands/command.py @@ -4,6 +4,8 @@ import traceback from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional import revolt +import inspect +from contextlib import suppress from revolt.utils import copy_doc if TYPE_CHECKING: @@ -27,7 +29,7 @@ class Command: aliases: list[:class:`str`] The aliases of the command """ - __slots__ = ("callback", "name", "aliases", "_client") + __slots__ = ("callback", "name", "aliases", "_client", "signature") _client: revolt.Client @@ -35,8 +37,9 @@ class Command: self.callback = callback self.name = name self.aliases = aliases + self.signature = inspect.signature(self.callback) - async def invoke(self, context: Context, args: list[Any]) -> Any: + async def invoke(self, context: Context, *args, **kwargs) -> Any: """Runs the command and calls the error handler if the command errors. Parameters @@ -47,17 +50,17 @@ class Command: The arguments for the command """ try: - return await self.callback(self._client, context, *args) + return await self.callback(self._client, context, *args, **kwargs) except Exception as err: return await self._error_handler(context, err) @copy_doc(invoke) - def __call__(self, context: Context, args: list[Any]) -> Any: - return self.invoke(context, args) + def __call__(self, context: Context, *args, **kwargs) -> Any: + return self.invoke(context, *args, **kwargs) def error(self, func: Callable[..., Coroutine[Any, Any, Any]]): """Sets the error handler for the command. - + Parameters ----------- func: Callable[..., Coroutine[Any, Any, Any]] @@ -79,25 +82,58 @@ class Command: async def _error_handler(ctx: Context, error: Exception): traceback.print_exception(type(error), error, error.__traceback__) + @staticmethod + async def convert_argument(arg: str, parameter: inspect.Parameter, context: Context): + if annot := parameter.annotation: + func = getattr(annot, "__convert__") + + if func: + output = func(arg, context) + else: + output = annot(arg) + + if inspect.isawaitable(output): + output = await output + + return output + + else: + return arg + + async def parse_arguments(self, context: Context): + for name, parameter in list(self.signature.parameters.items())[2:]: + if parameter.kind == parameter.KEYWORD_ONLY: + context.kwargs[name] = await self.convert_argument(context.view.get_rest(), parameter, context) + + elif parameter.kind == parameter.VAR_POSITIONAL: + with suppress(StopIteration): + while True: + context.args.append(await self.convert_argument(context.view.get_next_word(), parameter, context)) + + elif parameter.kind == parameter.POSITIONAL_OR_KEYWORD: + context.args.append(await self.convert_argument(context.view.get_next_word(), parameter, context)) + def __repr__(self) -> str: return f"" def command(*, name: Optional[str] = None, aliases: Optional[list[str]] = None, cls: type[Command] = Command): - """Turns a function into a :class:`Command`. - + """A decorator that turns a function into a :class:`Command`. + Parameters ----------- name: Optional[:class:`str`] The name of the command, this defaults to the functions name aliases: Optional[list[:class:`str`]] The aliases of the command, defaults to no aliases - + cls: type[:class:`Command`] + The class used for creating the command, this defaults to :class:`Command` but can be used to use a custom command subclass + Returns -------- - :class:`Command` - The command + Callable[Callable[..., Coroutine], :class:`Command`] + A function that takes the command callback and returns a :class:`Command` """ def inner(func: Callable[..., Coroutine[Any, Any, Any]]): return cls(func, name or func.__name__, aliases or []) - + return inner diff --git a/revolt/ext/commands/context.py b/revolt/ext/commands/context.py index 2a9bade..ce04c29 100755 --- a/revolt/ext/commands/context.py +++ b/revolt/ext/commands/context.py @@ -1,12 +1,15 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any, Optional, TYPE_CHECKING import revolt from revolt.utils import copy_doc from .command import Command +if TYPE_CHECKING: + from .view import StringView + __all__ = ( "Context", ) @@ -14,7 +17,7 @@ __all__ = ( class Context: """Stores metadata the commands execution. - Parameters + Attributes ----------- command: Optional[:class:`Command`] The command, this can be `None` when no command was found and the error handler is being executed @@ -29,15 +32,19 @@ class Context: author: Union[:class:`Member`, :class:`User`] The user or member that invoked the commad, will be :class:`User` in DMs args: list[:class:`str`] - The arguments being passed to the command + The positional arguments being passed to the command + kwargs: dict[:class:`str`, Any] + The keyword arguments being passed to the command """ - __slots__ = ("command", "invoked_with", "args", "message", "server", "channel", "author") + __slots__ = ("command", "invoked_with", "args", "message", "server", "channel", "author", "view", "kwargs") - def __init__(self, command: Optional[Command], invoked_with: str, args: list[str], message: revolt.Message): + def __init__(self, command: Optional[Command], invoked_with: str, view: StringView, message: revolt.Message): self.command = command self.invoked_with = invoked_with - self.args = args + self.view = view self.message = message + self.args = [] + self.kwargs = {} self.server = message.server self.channel = message.channel self.author = message.author @@ -52,8 +59,11 @@ class Context: args: list[:class:`str`] The args being passed to the command """ + if command := self.command: - return await command.invoke(self, self.args) + await command.parse_arguments(self) + + return await command.invoke(self, *self.args, **self.kwargs) @copy_doc(revolt.Messageable.send) async def send(self, content: Optional[str] = None, *, embeds: Optional[list[revolt.Embed]] = None, embed: Optional[revolt.Embed] = None, attachments: Optional[list[revolt.File]] = None) -> revolt.Message: diff --git a/revolt/ext/commands/mixin.py b/revolt/ext/commands/mixin.py index 9a82b53..593498c 100755 --- a/revolt/ext/commands/mixin.py +++ b/revolt/ext/commands/mixin.py @@ -4,10 +4,13 @@ from typing import Any, Callable, Union import re import revolt +import traceback +from .view import StringView from .command import Command from .context import Context -from .errors import CommandNotFound, NoClosingQuote +from .errors import CommandNotFound + __all__ = ( "CommandsMeta", @@ -43,7 +46,7 @@ class CommandsMixin(metaclass=CommandsMeta): dispatch: Callable[..., None] def __init__(self, *args, **kwargs): - self.all_commands = {} + self.all_commands: dict[str, Command] = {} for command in self._commands: self.all_commands[command.name] = command @@ -99,42 +102,8 @@ class CommandsMixin(metaclass=CommandsMeta): """ self.all_commands[name] = command - def split_content(self, content: str) -> list[str]: - """Splits a string into seperate parameters, overwrite this function to change how arguments are split. - - Parameters - ----------- - content: :class:`str` - The content of the message - - Returns - -------- - list[:class:`str`] - The arguments from the content - """ - args = [] - i = 0 - - while i < len(content): - char = content[i] - - if re.match(quote_regex, char): - try: - j = content.index(char, i + 1) - args.append(content[i + 1:j]) - i = j - except ValueError: - raise NoClosingQuote("Missing closing quote.") - else: - match = chunk_regex.match(content, i) - - if match: - args.append(match.group()) - i = match.end() - - i += 1 - - return args + def get_view(self, message: revolt.Message) -> type[StringView]: + return StringView def get_context(self, message: revolt.Message) -> type[Context]: return Context @@ -172,18 +141,31 @@ class CommandsMixin(metaclass=CommandsMeta): if not content: return - command_name, *args = self.split_content(content) + view = StringView(content) + + try: + command_name = view.get_next_word() + except StopIteration: + return + + context_cls = self.get_context(message) try: command = self.get_command(command_name) except KeyError: - return self.dispatch("command_error", Context(None, command_name, args, message), CommandNotFound(command_name)) + context = context_cls(None, command_name, view, message) + return self.dispatch("command_error", context, CommandNotFound(command_name)) - context = self.get_context(message)(command, command_name, args, message) + context = context_cls(command, command_name, view, message) try: return await context.invoke() except Exception as e: self.dispatch("command_error", context, e) + + @staticmethod + async def command_error(ctx: Context, error: Exception): + traceback.print_exception(type(error), error, error.__traceback__) + on_message = process_commands diff --git a/revolt/ext/commands/view.py b/revolt/ext/commands/view.py new file mode 100755 index 0000000..4893665 --- /dev/null +++ b/revolt/ext/commands/view.py @@ -0,0 +1,39 @@ +from .errors import NoClosingQuote + + +class StringView: + def __init__(self, string: str): + self.value = iter(string) + + def next_char(self) -> str: + return next(self.value) + + def get_rest(self) -> str: + return "".join(self.value) + + def get_next_word(self) -> str: + char = self.next_char() + temp = [] + + while char == " ": + char = self.next_char() + + if char in ["\"", "'"]: + quote = char + try: + while (char := self.next_char()) != quote: + temp.append(char) + except StopIteration: + raise NoClosingQuote + + else: + temp.append(char) + try: + while (char := self.next_char()) != " ": + temp.append(char) + except StopIteration: + pass + + output = "".join(temp) + + return output