add proper argument parsing - converters and keyword arguments

This commit is contained in:
Zomatree
2021-10-30 15:57:03 +01:00
parent 77fa908ad5
commit 2a3071712e
4 changed files with 127 additions and 60 deletions
+48 -12
View File
@@ -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"<Command name={self.name}>"
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
+17 -7
View File
@@ -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:
+23 -41
View File
@@ -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
+39
View File
@@ -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