diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b6ce6b..8dfa222 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,10 @@ # Making NioBot better -Pull requests and issues are always welcome for niobot! Here's a few things to keep in mind when contributing: +Issues and pull requests are the lifeblood of nio-bot. Opening issues is one of the most helpful things you can do, +and pull requests are even better. This document will guide you through the process of contributing to nio-bot. +From hereon, the term "contributing" will refer to both opening issues and pull requests, picking which is applicable. + +Here's a few things to keep in mind when contributing: ## Scope @@ -39,34 +43,25 @@ that is too complicated, it may be difficult to maintain, and may be removed in ## Code style -~~NioBot uses a very loose code style, however generally, make sure your code is readable, and that the max line length -of your code is 120 characters at a max, preferably less than 119.~~ - -~~If you are unsure about the style of your code, you should run `black ./src` and `isort` in the root directory of the -project. In `pyproject.toml`, all the configuration for those tools is already set up, so you don't need to worry about -command line flags.~~ - -~~You should also ensure there are no warnings with `pycodestyle`.~~ - NioBot now makes use of `ruff` for code formatting, and this is automatically enforced by the CI. You should run `ruff format` in the root directory of the project to format your code before submitting a pull request. The rules that are used for formatting are already pre-created in the pyproject.toml file, so you do not need to worry about arguments. If you just want to check that your code is following the code style without making any changes, run `ruff check`. -### Versions +Pre-commit is available if you desire, and there are CI checks to ensure that your code is formatted correctly. -NioBot uses SemVer (semantic versioning) for versioning. This means that the version number is split into three parts: -`Major`, `Minor` and `Patch`. As per the versioning, `Major` versions are not guaranteed to be backwards compatible, -however `Minor` and `Patch` versions are. +### Versions -This means that there will always be a new `Major` increment when a backwards incompatible change is made, and a new -`Minor` increment when a backwards compatible change is made. `Patch` versions are almost always bug fixes, and are -always backwards compatible. If a bug fix is not backwards compatible, a new `Major` version will be released. +NioBot very loosely uses [Semantic Versioning](https://semver.org/). +You've probably heard of semver before, but if you haven't there's 3 parts to a version number: `MAJOR.MINOR.PATCH`. +These are incremented with MAJOR (API incompatible) changes, MINOR (backwards-compatible) changes, +and PATCH (backwards-compatible bug fixes) changes. -Note that in the event a breaking however minor change is made, `Minor` will be the only one increased. For example, -if there's a simple parameter change (e.g. name or type or becomes required), `Minor` will be incremented, however -old signatures and methods will still exist for the rest of the current Major release, or for 5 future Minor versions. +When "loosely uses" is used, it means that nio-bot will try to follow semver as closely as possible, however, +the MAJOR segment does not get bumped with every breaking change, only if there are several. +For example, if a non-core function signature changes incompatibly, it will not bump the major version. However, if +several functions change incompatibly, it will bump the major version. Major changes may be pushed into their own branches for "feature previews". These branches will be prefixed with `feature/`, and will be merged into `master` when they are ready for release. For example, `feature/my-thing`, @@ -91,12 +86,15 @@ release candidates. Furthermore, in the interest of backward compatibility, it may take a while until nio-bot supports the latest language features. Keep this in mind. +**End of life versions are never actively supported**. See [EOL.date](https://endoflife.date/python) for more +information. + # Community The great thing about open source software is the ability for anyone to read, understand, and contribute to it. NioBot, with our strong copy-left [LGPLv3](/LICENSE) license, is no exception. -If you think there's something that could benefit nio-bot users, however don't think its in scope or relevant to the +If you think there's something that could benefit nio-bot users, however don't think it's in scope or relevant to the core library, you are welcome to create a community plugin. Community plugins are plugins that are not part of the core library, however can still be installed and used by nio-bot users. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index e3dba23..11082e2 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -2,4 +2,65 @@ Using commands and events is the main way to interact with the bot. +## Command argument detection + +One of the most powerful features of NioBot is the command argument interpretation system. +When you create a niobot command, the arguments are automatically detected, and their desired +type is inferred from the type hints in the function signature. + +This means that `foo: str` will always give you a string, `bar: int` will try to give you an integer, +or throw an error if it cannot convert the user-given argument. + +As of v1.2.0, you can take advantage of the keyword-only and positional args in Python. +Normally, when you specify a function like `async def mycommand(ctx, x: str)`, niobot will see +that you want an argument, x, and will do just that. It will take the user's input, and give you +the value for x. However, if the user specifies multiple words for `x`, it will only give the first one +to the function, unless the user warps the argument in "quotes". + +```python +import niobot +bot = niobot.NioBot() + +@bot.command() +async def mycommand(ctx, x: str): + await ctx.respond(f"Your argument was: {x}") +``` +If you ran `!mycommand hello world`, the bot would respond with `Your argument was: hello`. + +With keyword-only arguments, you can make use of "greedy" arguments. +While you could previously do this by *manually* constructing the [niobot.Argument][] type, +you can now do this with the `*` syntax in Python. + +```python +import niobot +bot = niobot.NioBot() + +@bot.command() +async def mycommand(ctx, *, x: str): + await ctx.respond(f"Your argument was: {x}") +``` +If you ran `!mycommand hello world`, the bot would respond with `Your argument was: hello world`. + +And, as for positional args, if you want to fetch a set of arguments, you can do so by specifying +`*args`. This will give you a tuple containing every whitespace-delimited argument after the command. + +```python +import niobot +bot = niobot.NioBot() + +@bot.command() +async def mycommand(ctx, *args: str): + await ctx.respond(f"Your arguments were: {args}") +``` +If you ran `!mycommand hello world`, the bot would respond with `Your arguments were: ('hello', 'world')`. + +!!! danger "Position & KW-Only args are final and strings!" + If you specify a keyword or positional argument, you cannot have any arguments afterwards. + Furthermore, (currently) both of these arguments are always strings. Trying to specify + another type will throw an error. + +--- + +## Reference + ::: niobot.commands diff --git a/src/niobot/attachment.py b/src/niobot/attachment.py index 464adf9..5c6562a 100644 --- a/src/niobot/attachment.py +++ b/src/niobot/attachment.py @@ -25,7 +25,14 @@ import aiofiles import aiohttp import blurhash -import magic + +try: + import magic +except ImportError: + logging.getLogger(__name__).critical( + "Failed to load magic. Automatic file type detection will be unavailable. Please install python3-magic." + ) + magic = None import nio from .exceptions import ( @@ -95,7 +102,11 @@ def detect_mime_type(file: U[str, io.BytesIO, pathlib.Path]) -> str: :param file: The file to detect the mime type of. Can be a BytesIO. :return: The mime type of the file (e.g. `text/plain`, `image/png`, `application/pdf`, `video/webp` etc.) + :raises RuntimeError: If the `magic` library is not installed. + :raises TypeError: If the file is not a string, BytesIO, or Path object. """ + if not magic: + raise RuntimeError("magic is not installed. Please install it to use this function.") if isinstance(file, str): file = pathlib.Path(file) diff --git a/src/niobot/client.py b/src/niobot/client.py index 59909fe..69605b0 100644 --- a/src/niobot/client.py +++ b/src/niobot/client.py @@ -220,7 +220,8 @@ def __init__( elif asyncio.iscoroutinefunction(cmd) or inspect.isfunction(cmd): self.log.warning( "Manually changing default help command callback to %r. Please consider passing your own" - " Command instance instead." + " Command instance instead.", + cmd, ) help_cmd.callback = cmd else: diff --git a/src/niobot/commands.py b/src/niobot/commands.py index d44cc36..535be70 100644 --- a/src/niobot/commands.py +++ b/src/niobot/commands.py @@ -57,6 +57,7 @@ def __init__( required: bool = ..., parser: typing.Callable[["Context", "Argument", str], typing.Optional[_T]] = ..., greedy: bool = False, + raw_type: type(inspect.Parameter.POSITIONAL_OR_KEYWORD), **kwargs, ): if default is inspect.Parameter.default: @@ -73,7 +74,7 @@ def __init__( self.extra = kwargs self.parser = parser self.greedy = greedy - + self.raw_type = raw_type if self.parser is ...: from .utils import BUILTIN_MAPPING @@ -118,6 +119,9 @@ def __init__( ) ) + if raw_type == inspect.Parameter.KEYWORD_ONLY and self.type is not str: + raise TypeError("Keyword-only arguments must be of type str, not %r." % self.type) + def __repr__(self): return ( " list[Argument]: continue # Disallow **kwargs - if parameter.kind in [inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.VAR_KEYWORD]: - raise CommandArgumentsError("Cannot use keyword args in command callback (argument No. %d)" % n) - is_positional = parameter.kind == inspect.Parameter.VAR_POSITIONAL + if parameter.kind == inspect.Parameter.POSITIONAL_ONLY: + raise TypeError("Positional-only arguments are not supported.") + elif parameter.kind == inspect.Parameter.VAR_KEYWORD: + raise TypeError("Implicit keyword arguments (**kwargs) are not supported.") + is_positional = parameter.kind == inspect.Parameter.VAR_POSITIONAL # *args + is_kwarg = parameter.kind == inspect.Parameter.KEYWORD_ONLY + greedy = is_positional or is_kwarg if parameter.annotation is inspect.Parameter.empty: log.debug("Found argument %r, however no type was specified. Assuming string.", parameter) - a = Argument(parameter.name, str, default=parameter.default, greedy=is_positional) + a = Argument(parameter.name, str, default=parameter.default, greedy=greedy, raw_type=parameter.kind) else: annotation = parameter.annotation origin = typing.get_origin(annotation) @@ -269,24 +284,40 @@ def autodetect_args(callback) -> list[Argument]: type_parser, ) a = Argument( - parameter.name, real_type, default=parameter.default, parser=type_parser, greedy=is_positional + parameter.name, + real_type, + default=parameter.default, + parser=type_parser, + greedy=greedy, + raw_type=parameter.kind, ) elif origin is typing.Union: if len(annotation_args) == 2 and annotation_args[1] is type(None): log.debug("Resolved Union[...] (%r) to optional type %r", annotation, annotation_args[0]) a = Argument( - parameter.name, annotation_args[0], default=parameter.default, greedy=is_positional + parameter.name, + annotation_args[0], + default=parameter.default, + greedy=greedy, + raw_type=parameter.kind, ) else: raise CommandArgumentsError("Union types are not yet supported (argument No. %d)." % n) else: log.debug("Found argument %r with unknown annotated type %r", parameter, parameter.annotation) - a = Argument(parameter.name, parameter.annotation) + a = Argument( + parameter.name, + parameter.annotation, + default=parameter.default, + greedy=greedy, + raw_type=parameter.kind, + ) if parameter.default is not inspect.Parameter.empty: a.default = parameter.default a.required = False args.append(a) + # NOTE: It may be worth breaking here, but an error should be raised if there are too many arguments. log.debug("Automatically detected the following arguments: %r", args) return args @@ -345,6 +376,55 @@ async def can_run(self, ctx: Context) -> bool: raise CheckFailure(name) return True + async def parse_args( + self, ctx: Context + ) -> typing.Dict[Argument, typing.Union[typing.Any, typing.List[typing.Any]]]: + """ + Parses the arguments for the current command. + """ + sentinel = os.urandom(128) # forbid passing arguments with this failsafe + to_pass = {} + hit_greedy = False + self.log.debug("Parsing arguments for command %r: %r", self, self.arguments) + for arg in self.arguments[1:]: # 0 is ctx + if hit_greedy: + raise TypeError("Got an argument after a greedy=True argument.") + to_pass[arg] = sentinel + if arg.greedy: + to_pass[arg] = [] + hit_greedy = True + next_arg = 1 + + context_args = iter(ctx.args) + for value in context_args: + try: + arg = self.arguments[next_arg] + except IndexError: + raise CommandArgumentsError(f"Too many arguments given to command {self.name}") + + self.log.debug("Parsing argument %d: %r, with value %r", next_arg, arg, value) + try: + parsed = arg.parser(ctx, arg, value) + if inspect.iscoroutine(parsed): + parsed = await parsed + except Exception as e: + raise CommandParserError(f"Error while parsing argument {arg.name}: {e}") from e + self.log.debug("Parsed argument %d (%r<%r>) to %r", next_arg, arg, value, parsed) + if arg.greedy: + to_pass[arg].append(parsed) + else: + to_pass[arg] = parsed + next_arg += 1 + + for arg, value in to_pass.items(): + if value is sentinel and arg.required: + raise CommandArgumentsError(f"Missing required argument {arg.name}") + if value is sentinel: + to_pass[arg] = arg.default + if arg.greedy and arg.raw_type == inspect.Parameter.KEYWORD_ONLY: + to_pass[arg] = " ".join(to_pass[arg]) + return to_pass + async def invoke(self, ctx: Context) -> typing.Coroutine: """ Invokes the current command with the given context @@ -353,58 +433,14 @@ async def invoke(self, ctx: Context) -> typing.Coroutine: :raises CommandArgumentsError: Too many/few arguments, or an error parsing an argument. :raises CheckFailure: A check failed """ - - parsed_args = [] - if len(ctx.args) > (len(self.arguments) - 1) and self.greedy is False: - raise CommandArgumentsError(f"Too many arguments given to command {self.name}") - for index, argument in enumerate(self.arguments[1:]): - argument: Argument - - if index >= len(ctx.args): - if argument.required: - raise CommandArgumentsError(f"Missing required argument {argument.name}") - parsed_args.append(argument.default) - continue - - self.log.debug("Resolved argument %s to %r", argument.name, ctx.args[index]) - try: - parsed_argument = argument.parser(ctx, argument, ctx.args[index]) - if inspect.iscoroutine(parsed_argument): - parsed_argument = await parsed_argument - except Exception as e: - error = f"Error while parsing argument {argument.name}: {e}" - raise CommandArgumentsError(error) from e - parsed_args.append(parsed_argument) - - # Greedy args support - if argument.greedy: - for arg in ctx.args[index + 1 :]: - # noinspection PyBroadException - try: - parsed_argument = argument.parser(ctx, argument, arg) - if inspect.iscoroutine(parsed_argument): - parsed_argument = await parsed_argument - except Exception: - break - parsed_args.append(parsed_argument) - self.log.debug("Resolved greedy argument %s to %r", argument.name, arg) - break - - parsed_args = [ctx, *parsed_args] - if len(parsed_args) < len(self.arguments): - self.log.warning( - "Parsed arguments length does not match registered arguments length. %d processed arguments, %d " - "arguments.", - len(parsed_args), - len(self.arguments), - ) + parsed_kwargs = await self.parse_args(ctx) + parsed_args = [ctx] + if self.module: + parsed_args.insert(0, self.module) self.log.debug("Arguments to pass: %r", parsed_args) + self.log.debug("Keyword arguments to pass: %r", parsed_kwargs) ctx.client.dispatch("command", ctx) - if self.module: - self.log.debug("Will pass module instance") - return self.callback(self.module, *parsed_args) - else: - return self.callback(*parsed_args) + return self.callback(*parsed_args, **{x.name: y for x, y in parsed_kwargs.items()}) def construct_context( self, diff --git a/src/niobot/utils/parsers.py b/src/niobot/utils/parsers.py index 27a3ec1..7e1de61 100644 --- a/src/niobot/utils/parsers.py +++ b/src/niobot/utils/parsers.py @@ -115,6 +115,11 @@ def parse(cls, ctx: "Context", arg: "Argument", value: str) -> typing.Optional[t return cls()(ctx, arg, value) +class _StringParser(StatelessParser): + def __call__(self, ctx, arg, value) -> str: + return str(value) + + class BooleanParser(StatelessParser): """ Converts a given string into a boolean. Value is casefolded before being parsed. @@ -417,6 +422,7 @@ def __call__(self, ctx, arg, value): BUILTIN_MAPPING = { + str: _StringParser(), bool: BooleanParser(), float: FloatParser(), int: IntegerParser(),