diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..dcc253a --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +inline-quotes = double diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..90f2230 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,11 @@ +#!/bin/sh +# +# This hooks runs tests and checks if the linter doesn't hate you + +# Linting +printf "black......" +make format > /dev/null 2>&1 && printf "OK!\n" || { printf "Fail." && exit 1; } + +# Running tests +printf "pytest....." +make test > /dev/null 2>&1 && printf "OK!\n" || { printf "Fail." && exit 1; } diff --git a/.gitignore b/.gitignore index a65b273..b20fe45 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,9 @@ build/ *.egg-info/ *.eggs/ -# Docs output +# Docs docs/build +docs/source/apidoc/ # Caches __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bde1806..eae002c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ -# Changelog +## Unreleased +### Added +- Daemons can now accept an interval value, removing the need for a manual while True loop + +## v0.1 (2020/08/26) -## 0.1 (2020/08/26) ### Added - Prefix can now be passed as argument to init - Pre-made help module diff --git a/Makefile b/Makefile index 2f30a78..0431562 100644 --- a/Makefile +++ b/Makefile @@ -10,10 +10,10 @@ DOCS=docs # Interpreter to create venv with INTERPRETER=python3.8 -all: run +all: build-venv -# Re-create venv when needed +# =====VENV===== $(VENV)/bin/activate: requirements.txt requirements-dev.txt @ echo "Rebuilding venv..." @ [ ! -e "$(VENV)" ] || rm -rf "$(VENV)" @@ -22,8 +22,9 @@ $(VENV)/bin/activate: requirements.txt requirements-dev.txt build-venv: $(VENV)/bin/activate + # =====CLEANING===== -clean: clean-venv clean-cache clean-docs +clean: clean-venv clean-cache clean-docs clean-setup # Remove venv clean-venv: @@ -37,24 +38,41 @@ clean-cache: @ echo "Removing caches..." @ find . -type d \( -name "__pycache__" -o -name ".pytest_cache" \) -exec rm -r "{}" + +# Removed generation documentation clean-docs: @ echo "Removing documentation build..." @ [ ! -e "$(DOCS)/build" ] || rm -r "$(DOCS)/build" + @ [ ! -e "$(DOCS)/source/apidoc" ] || rm -r "$(DOCS)/source/apidoc" +# Remove build leftovers (not dist) clean-setup: @ echo 'Removing build artifacts...' @ [ ! -e "build" ] || rm -rf build @ find . -maxdepth 1 -type d -name '*.egg-info' -exec rm -rf "{}" \; + # =====DOCS===== -docs: build-venv - @ "$(VENV)/bin/sphinx-apidoc" -o "$(DOCS)/source" "$(SRC)" +# # Generate documentation +docs: docs/source/conf.py docs/source/index.rst build-venv + @ "$(VENV)/bin/sphinx-apidoc" -f -o "$(DOCS)/source/apidoc" "$(SRC)" @ "$(VENV)/bin/sphinx-build" "$(DOCS)/source" "$(DOCS)/build" # =====TESTS===== +# Run tests test: pytest.ini build-venv - @ "$(VENV)/bin/pytest" --color=yes + @ "$(VENV)/bin/pytest" --color=auto + + +# =====LINTING===== +# Run flake8 +lint: build-venv + @ "$(VENV)/bin/flake8" "$(SRC)"/**/*.py + +# =====FORMATTING===== +# Run black +format: build-venv + @ "$(VENV)/bin/black" '$(SRC)' # =====PACKAGING===== @@ -62,7 +80,7 @@ package: README.md LICENSE setup.py test clean-setup @ echo "Removing build..." @ [ ! -e "dist" ] || rm -r "dist" @ echo "Running setup.py..." - @ "$(VENV)/bin/python" setup.py sdist bdist_wheel + @ "$(VENV)/bin/python" setup.py bdist_wheel publish: package @ [ "$$(git symbolic-ref HEAD --short)" = master ] && { \ diff --git a/README.md b/README.md index 142d880..bdb6600 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,13 @@ on writing the functionality of the bot itself, not how the bot works/interacts Frank works by dividing the bot into modules. Each module has its own prefix, commands, and daemons. Frank handles routing the Discord commands to their respective functions. +## Installation +You can install the `frank-discord` package from PyPi: + +``` +pip install frank-discord +``` + ## Example Module In this section, I've written an example module for you, to understand the basic mechanics behind Frank. @@ -29,7 +36,7 @@ This first part shows the three important variables in any module. With fr being the default prefix for Frank (can be overwritten). As you define more modules, they should all have a unique prefix. This is how Frank's modular system works, and any modules added to the list will automatically be picked up by Frank. The PREFIX value can also be list, allowing for multiple prefixes: for example a long, - description one, and a short, easy to type one (e.g. minecraft and mc). + descriptive one, and a short, easy to type one (e.g. minecraft and mc). ```python def pre_start(self): @@ -46,14 +53,14 @@ Frank. # do some stuff pass - @frank.daemon() + # Interval defines how many seconds are between each call + @frank.daemon(interval=5) async def some_daemon(self): - while True: - # do some stuff - pass + # do some stuff + pass @frank.default() - async def default_cmd(self): + async def default_cmd(self, author, channel, mid): # do some default action pass ``` @@ -66,12 +73,24 @@ These three decorators are the bread and butter of Frank. Let's break them down: fr examp command [ARGS] ``` This is how you can define as many Discord commands as you want, without needing to know how to parse the messages - etc. Each command gets the `author`, `channel`, and `id` of the message. The `cmd` variable contains all the arguments passed - to the command. + etc. Each command gets the `author`, `channel`, and `id` of the message. The `cmd` variable contains all the + arguments passed to the command. - `frank.daemon` defines a daemon, a process that should run in the background for as long as the bot is active. It should contain a while loop and preferably a sleep function using `asyncio.sleep()` (there are plans to improve this behavior). Because a daemon is just a method of the module class, it has access to all class variables, including those defined in `pre_start`. -- `frank.default` defines the command that should be run if the module is called without explicitely giving a command. +- `frank.default` defines the command that should be run if the module is called without explicitly giving a command. For example, if you call `fr examp` without specifying a command, it will run the default command. This is useful for making a command that's used very often easier to execute. + +In the end, all you need to do is add the following in your main script: + +```python +from frank import Frank +# Or whatever your package containing your modules is called +from modules import ExampleMod + +if __name__ == '__main__': + client = Frank([ExampleMod]) + client.run(YOUR_DISCORD_TOKEN) +``` diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..9534b01 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..e31b07d --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,57 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys +sys.path.insert(0, os.path.abspath('../..')) + + +# -- Project information ----------------------------------------------------- + +project = 'Frank' +author = 'Jef Roosens' +copyright = f'2020, {author}' # noqa: A001,VNE003 + +# The full version, including alpha/beta/rc tags +release = '0.1' + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.napoleon', + 'sphinx_rtd_theme', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..902759e --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,20 @@ +Welcome to Frank's documentation! +================================= + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + +Installation +============ + +You can install Frank using pip:: + + pip install frank-discord + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/frank/__init__.py b/frank/__init__.py index 0468a36..e5cf103 100644 --- a/frank/__init__.py +++ b/frank/__init__.py @@ -1,18 +1,25 @@ from .frank import Frank from .module import ( - Module, command, Command, default, Default, daemon, Daemon, - regex_command, RegexCommand, + Module, + command, + Command, + default, + Default, + daemon, + Daemon, + regex_command, + RegexCommand, ) __all__ = [ - 'Frank', - 'Module', - 'command', - 'Command', - 'default', - 'Default', - 'daemon', - 'Daemon', - 'regex_command', - 'RegexCommand', + "Frank", + "Module", + "command", + "Command", + "default", + "Default", + "daemon", + "Daemon", + "regex_command", + "RegexCommand", ] diff --git a/frank/frank.py b/frank/frank.py index 62c52ab..eb84d64 100644 --- a/frank/frank.py +++ b/frank/frank.py @@ -11,6 +11,7 @@ import discord # Typing imports from typing import TYPE_CHECKING, List + if TYPE_CHECKING: # Own imports from .module import Module @@ -23,8 +24,12 @@ class Frank(discord.Client): their own behavior. """ - def __init__(self, modules: List[Module], config_file: str = 'frank.yaml', - prefix: str = 'fr'): + def __init__( + self, + modules: List[Module], + config_file: str = "frank.yaml", + prefix: str = "fr", + ): """ Args: modules: modules to load @@ -39,7 +44,7 @@ class Frank(discord.Client): self.PREFIX = prefix try: - with open(config_file, 'r') as f: + with open(config_file, "r") as f: self._config = yaml.load(f, Loader=yaml.FullLoader) except FileNotFoundError: @@ -50,7 +55,7 @@ class Frank(discord.Client): Runs when the bot has succesfully connected to Discord """ - print('Connected') + print("Connected") # Startup all modules for module in self._modules: @@ -63,7 +68,7 @@ class Frank(discord.Client): await loaded._start() self._loaded_modules.append(loaded) - print('All modules loaded') + print("All modules loaded") async def stop(self): """ @@ -88,10 +93,18 @@ class Frank(discord.Client): except ValueError: return - if cmd and cmd[0] == self.PREFIX: - module = next((mod for mod in self._loaded_modules - if mod.match(cmd[1])), None) + # Exit if no commands are given or prefix is wrong + if not (cmd and cmd[0] == self.PREFIX): + return - if module: - await module(cmd=cmd[2:], author=message.author, - channel=message.channel, mid=message.id) + module = next( + (mod for mod in self._loaded_modules if mod.match(cmd[1])), None + ) + + if module: + await module( + cmd=cmd[2:], + author=message.author, + channel=message.channel, + mid=message.id, + ) diff --git a/frank/module/__init__.py b/frank/module/__init__.py index 322a87d..459ef30 100644 --- a/frank/module/__init__.py +++ b/frank/module/__init__.py @@ -1,18 +1,24 @@ from .module import Module from .decorators import ( - command, Command, default, Default, daemon, Daemon, regex_command, + command, + Command, + default, + Default, + daemon, + Daemon, + regex_command, RegexCommand, ) __all__ = [ - 'Module', - 'command', - 'Command', - 'default', - 'Default', - 'daemon', - 'Daemon', - 'regex_command', - 'RegexCommand', + "Module", + "command", + "Command", + "default", + "Default", + "daemon", + "Daemon", + "regex_command", + "RegexCommand", ] diff --git a/frank/module/decorators/__init__.py b/frank/module/decorators/__init__.py index 1c8ce8e..67c183b 100644 --- a/frank/module/decorators/__init__.py +++ b/frank/module/decorators/__init__.py @@ -1,13 +1,13 @@ -from .classes import Command, RegexCommand, Daemon, Default +from .classes import RegularCommand, RegexCommand, Daemon, Default from .functions import command, regex_command, daemon, default __all__ = [ - 'command', - 'Command', - 'regex_command', - 'RegexCommand', - 'default', - 'Default', - 'daemon', - 'Daemon', + "command", + "RegularCommand", + "regex_command", + "RegexCommand", + "default", + "Default", + "daemon", + "Daemon", ] diff --git a/frank/module/decorators/classes.py b/frank/module/decorators/classes.py index 3e93be1..9d10a82 100644 --- a/frank/module/decorators/classes.py +++ b/frank/module/decorators/classes.py @@ -4,6 +4,15 @@ from __future__ import annotations # Built-in imports import re +import asyncio +import shlex + +# Typing imports +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # Built-in imports + from typing import Union, List, Tuple class Simple: @@ -45,10 +54,18 @@ class Simple: return self + @property + def client(self): + """ + Returns the Frank client instance. + """ -class Command(Simple): + return self._obj._client + + +class SimpleCommand(Simple): """ - Represents a command of the module. + Base class for the various command types. """ def __init__(self, func: callable, cmd: str, help_str: str = None): @@ -64,47 +81,170 @@ class Command(Simple): self.cmd = cmd self.help_str = help_str - def match(self, prefix: str) -> bool: + def match(self, message: str) -> Tuple[bool, List[str]]: """ - Returns wether the command matches the given prefix. + Returns wether the command matches the given message. If the arguments + can't be parsed (e.g. unmatched quotes), it will return False as well. Args: - prefix: string to match own prefix against + message: message to check """ - return self.cmd == prefix + return self._match_full(message.split(" ")) + + def _match_full(self, parts: List[str]) -> Tuple[bool, List[str]]: + """ + Returns wether the message matches the full command. + + Args: + parts: parts of the message + """ + + # Can't match without 3 or more parts + if len(parts) < 3: + return False, None + + # Return False if it doesn't match + if not all( + ( + parts[0] == self.client.PREFIX, + parts[1] in self._obj.PREFIX, + parts[2] == self.cmd, + ) + ): + return False, None + + # Parse the output, and return True with the parsed items if it works, + # otherwise return False + try: + parsed = shlex.split(" ".join(parts[3:])) + + return True, parsed + + except ValueError: + return False, None -class RegexCommand(Command): +class RegularCommand(SimpleCommand): + """ + Defines a regular command; Handles command aliases as well. + """ + + def __init__( + self, + func: callable, + cmd: str, + help_str: str = None, + alias: Union[str, List[str]] = None, + requires_prefix: bool = False, + ): + super().__init__(self, func, cmd, help_str) + + self.alias = alias + + # This only matters for aliases + self.requires_prefix = requires_prefix + + # TODO: make this return the right value + def match(self, message: str) -> Tuple[bool, List[str]]: + """ + Returns wether the message matches the current command. + """ + + parts = message.split(" ") + + # If the alias doesn't match, return the full match, otherwise return + # alias + matches, parts = self._match_alias(parts) + + if matches: + return matches, parts + + return self._match_full(parts) + + # TODO: make this return the right value + def _match_alias(self, parts: List[str]) -> Tuple[bool, List[str]]: + """ + Returns wether the message matches an alias. + """ + + # Return False if there's only one part but a prefix is required + if self.requires_prefix and len(parts) == 1: + return False + + # Match with prefix + if self.requires_prefix: + return parts[0] == self.client.PREFIX and parts[1] in self.alias + + # Match without prefix + return parts[0] in self.alias + + +class RegexCommand(SimpleCommand): """ A subclass of Command that can use a regex pattern instead of a fixed prefix. """ - def match(self, prefix: str) -> bool: + def match(self, message: str) -> Tuple[str, List[str]]: """ Returns wether the regex pattern matches the given prefix. Args: prefix: string to match pattern against; Pattern must match entire - prefix + prefix """ - return bool(re.fullmatch(self.cmd, prefix)) + parts = message.split(" ") + matches = bool(re.fullmatch(self.cmd, parts[0])) + + # If it doesn't match, just return False, don't parse the rest + if not matches: + return False, None + + try: + parsed = shlex.split(" ".join(parts)) + + return True, parsed + + except ValueError: + return False, None class Daemon(Simple): """ - Represents a daemon. Currently, it's only used as its own type, but writing - it this way allows us to easily expand upon its functionality later. + Represents a daemon a.k.a. a background process. """ - pass + def __init__(self, func: callable, interval: Union[int, float] = 0): + """ + Args + func: function to wrap + interval: time between calls of the function; if <= 0, the function + is assumed to contain its own infinite loop, allowing for + fine-grained control of the daemon, if desired + """ + + super().__init__(func) + self.interval = interval + + # If an interval > 0 is given, it wraps the function inside an infinite + # loop with the desired delay + if interval > 0: + + async def loop(self, *args, **kwargs): + while True: + # TODO: does this make func and sleep run at the same time? + await func(self, *args, **kwargs) + + await asyncio.sleep(interval) + + self.func = loop class Default(Simple): """ - Represents a default command (a.k.a. when the module is called without a + Represents a default command a.k.a. when the module is called without a command. """ diff --git a/frank/module/decorators/functions.py b/frank/module/decorators/functions.py index 205cd87..60ef048 100644 --- a/frank/module/decorators/functions.py +++ b/frank/module/decorators/functions.py @@ -1,19 +1,37 @@ # =====IMPORTS===== +# Future imports +from __future__ import annotations + # Own imports -from .classes import Command, RegexCommand, Daemon, Default +from .classes import RegularCommand, RegexCommand, Daemon, Default + +# Typing imports +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # Built-in imports + from typing import Union, List -def command(cmd, help_str: str = None) -> callable: +def command( + cmd, + help_str: str = None, + alias: Union[str, List[str]] = None, + requires_prefix: bool = False, +) -> callable: """ Converts a method into a command by replacing it with a Command object. Args: cmd: keyword used to call this function help_str: short description of the command + alias: alias(es) for the command + requires_prefix: defines wether the command needs the Frank prefix for + its aliases to work or not """ def inner(func): - return Command(func, cmd, help_str) + return RegularCommand(func, cmd, help_str, aliases) return inner @@ -33,14 +51,14 @@ def regex_command(pattern: str, help_str: str = None) -> callable: return inner -def daemon() -> callable: +def daemon(interval: Union[int, float] = 0) -> callable: """ Converts the method into a Daemon, which will then be run when the module is started. """ def inner(func): - return Daemon(func) + return Daemon(func, interval) return inner diff --git a/frank/module/exceptions.py b/frank/module/exceptions.py index 4502971..d7c541b 100644 --- a/frank/module/exceptions.py +++ b/frank/module/exceptions.py @@ -7,4 +7,4 @@ class DuplicateCommand(Exception): class MultipleDefaults(Exception): - message = 'Multiple default commands detected' + message = "Multiple default commands detected" diff --git a/frank/module/meta.py b/frank/module/meta.py index c488b7e..c43f056 100644 --- a/frank/module/meta.py +++ b/frank/module/meta.py @@ -6,10 +6,11 @@ from __future__ import annotations from functools import cached_property # Own imports -from .decorators import Command, Daemon, Default, RegexCommand +from .decorators import SimpleCommand, Daemon, Default, RegexCommand # Typing imports from typing import TYPE_CHECKING + if TYPE_CHECKING: # Built-in imports from typing import List, Any @@ -18,7 +19,7 @@ if TYPE_CHECKING: class ModuleMeta: def _filter_attrs(self, condition: callable[[Any], bool]) -> List[Any]: # This prevents an infinite loop of getting the attribute - illegal_names = ['commands', 'daemons', 'default'] + illegal_names = ["commands", "daemons", "default"] output = [] @@ -31,13 +32,15 @@ class ModuleMeta: return output @cached_property - def commands(self) -> List[Command]: + def commands(self) -> List[SimpleCommand]: # This also matches RegexCommand objects - # The sort puts all the RegexCommand objects at the back, making them - # be matched last + # The sort puts all the RegexCommand objects at the back,making + # them be matched last - return sorted(self._filter_attrs(lambda val: isinstance(val, Command)), - key=lambda x: isinstance(x, RegexCommand)) + return sorted( + self._filter_attrs(lambda val: isinstance(val, SimpleCommand)), + key=lambda x: isinstance(x, RegexCommand), + ) @cached_property def daemons(self) -> List[Daemon]: @@ -45,5 +48,7 @@ class ModuleMeta: @cached_property def default(self) -> Default: - return next(iter(self._filter_attrs( - lambda val: isinstance(val, Default))), None) + return next( + iter(self._filter_attrs(lambda val: isinstance(val, Default))), + None, + ) diff --git a/frank/module/module.py b/frank/module/module.py index 5facd13..f666c25 100644 --- a/frank/module/module.py +++ b/frank/module/module.py @@ -12,6 +12,7 @@ from .decorators import RegexCommand # Typing imports from typing import TYPE_CHECKING + if TYPE_CHECKING: # Built-in imports from typing import List, Dict @@ -33,13 +34,13 @@ class Module(ModuleMeta): Prefix to activate this module. """ - NAME = '' + NAME = "" """ The name is used in various places, such as the config file and the help function. """ - HELP = '' + HELP = "" """ Short description of the module to use in the help function. """ @@ -84,8 +85,9 @@ class Module(ModuleMeta): for task in self._tasks: task.cancel() - async def __call__(self, cmd: List[str], author: User, - channel: Messageable, mid: int): + async def __call__( + self, cmd: List[str], author: User, channel: Messageable, mid: int + ): """ Execute the command, if found. @@ -97,21 +99,28 @@ class Module(ModuleMeta): """ if cmd: - func = next((func for func in self.commands - if func.match(cmd[0])), None) + func = next( + (func for func in self.commands if func.match(cmd[0])), None + ) - if func: - # A RegexCommand can use the prefix, as it's not a fixed string - if isinstance(func, RegexCommand): - await func(prefix=cmd[0], cmd=cmd[1:], author=author, - channel=channel, mid=mid) + # Throw error if no function is found + if not func: + raise InvalidCommand(f"Unknown command: {cmd}") - else: - await func(cmd=cmd[1:], author=author, channel=channel, - mid=mid) + # A RegexCommand can use the prefix, as it's not a fixed string + if isinstance(func, RegexCommand): + await func( + prefix=cmd[0], + cmd=cmd[1:], + author=author, + channel=channel, + mid=mid, + ) else: - raise InvalidCommand(f'Unknown command: {cmd}') + await func( + cmd=cmd[1:], author=author, channel=channel, mid=mid + ) elif self.default: await self.default(author=author, channel=channel, mid=mid) @@ -125,12 +134,12 @@ class Module(ModuleMeta): prefix: prefix to check """ - if cls.PREFIX: - if isinstance(cls.PREFIX, list): - return prefix in cls.PREFIX + # Always return False if there's no PREFIX defined + if not cls.PREFIX: + return False - else: - return prefix == cls.PREFIX + if isinstance(cls.PREFIX, list): + return prefix in cls.PREFIX else: - return False + return prefix == cls.PREFIX diff --git a/frank/modules/__init__.py b/frank/modules/__init__.py index 801d111..f6055c9 100644 --- a/frank/modules/__init__.py +++ b/frank/modules/__init__.py @@ -2,5 +2,5 @@ from .help import HelpMod __all__ = [ - 'HelpMod', + "HelpMod", ] diff --git a/frank/modules/help.py b/frank/modules/help.py index b8a6c61..76b6b83 100644 --- a/frank/modules/help.py +++ b/frank/modules/help.py @@ -10,6 +10,7 @@ from .. import Module, default, regex_command # Typing imports from typing import TYPE_CHECKING + if TYPE_CHECKING: # Built-in imports from typing import List @@ -24,11 +25,11 @@ class HelpMod(Module): other modules. """ - PREFIX = 'help' - NAME = 'help' - HELP = 'Shows help info about all modules' + PREFIX = "help" + NAME = "help" + HELP = "Shows help info about all modules" - @default(help_str='Show help about all modules.') + @default(help_str="Show help about all modules.") async def send_all(self, author: User, channel: Messageable, mid: int): embed = Embed() @@ -37,20 +38,33 @@ class HelpMod(Module): await channel.send(embed=embed) - @regex_command(cmd='.+', help_str='Show help about a certain module.') - async def show_module_help(self, prefix: str, cmd: List[str], author: User, - channel: Messageable, mid: int): + @regex_command(pattern=".+", help_str="Show help about a certain module.") + async def show_module_help( + self, + prefix: str, + cmd: List[str], + author: User, + channel: Messageable, + mid: int, + ): # Yes, this command just ignores cmd at the moment mod_name = prefix.lower() - mod = next((mod for mod in self._client._modules - if mod.NAME.lower() == mod_name), None) + mod = next( + ( + mod + for mod in self._client._modules + if mod.NAME.lower() == mod_name + ), + None, + ) if mod: embed = Embed() if mod.default: - embed.add_field(name='default', value=mod.default.help_str, - inline=False) + embed.add_field( + name="default", value=mod.default.help_str, inline=False + ) for cmd in mod._COMMANDS: embed.add_field(name=cmd.cmd, value=mod.help_str, inline=False) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a7e0353 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[tool.black] +line-length = 79 +target-version = ['py38'] +include = '\.pyi?$' +exclude = ''' + +( + /( + \.eggs + | \.git + | venv + | build + | dist + )/ +) +''' diff --git a/requirements-dev.txt b/requirements-dev.txt index 3395ec6..30c0a62 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,3 +11,6 @@ setuptools~=49.6.0 pytest>=6.0.1,<7.0.0 pytest-asyncio>=0.14.0,<1.0.0 twine>=3.2.0,<4.0.0 +Sphinx==3.2.1 +sphinx-rtd-theme==0.5.0 +black diff --git a/setup.py b/setup.py index 9238b29..2cd43fa 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,8 @@ # Third-party imports import setuptools +# TODO: switch to alternative that uses pyproject.toml (e.g. poetry) + with open('requirements.txt', 'r') as reqs_file: reqs = [line.strip() for line in reqs_file.readlines()] diff --git a/tests/test_decorators.py b/tests/test_decorators.py new file mode 100644 index 0000000..c9aa530 --- /dev/null +++ b/tests/test_decorators.py @@ -0,0 +1,44 @@ +# =====IMPORTS===== +# Third-party imports +import pytest + +# Own imports +from frank import default, command, daemon + + +class TestDecorators: + """ + This test makes sure the decorated functions return the same result as + their non-decorated counterparts + """ + + def default_no_dec(self): + return 'default' + + @default() + def default_dec(self): + return self.default_no_dec() + + def command_no_dec(self): + return 'command' + + @command('cmd') + def command_dec(self): + return self.command_no_dec() + + def daemon_no_dec(self): + return 'daemon' + + @daemon() + async def daemon_dec(self): + return self.daemon_no_dec() + + def test_default(self): + assert self.default_no_dec() == self.default_dec() + + def test_command(self): + assert self.command_no_dec() == self.command_dec() + + @pytest.mark.asyncio + async def test_daemon(self): + assert self.daemon_no_dec() == await self.daemon_dec()