mirror of
https://github.com/stoatchat/python-client-sdk.git
synced 2026-07-22 02:25:22 -04:00
add proper argument parsing - converters and keyword arguments
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+39
@@ -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
|
||||
Reference in New Issue
Block a user