Compare commits
19 Commits
Author | SHA1 | Date |
---|---|---|
Jef Roosens | 2a9323b5f5 | |
Jef Roosens | 32ab8538a4 | |
Jef Roosens | 7a7bd6fed4 | |
Jef Roosens | 6f2d9d1dde | |
Jef Roosens | 593fe189cd | |
Jef Roosens | 5855c931ca | |
Jef Roosens | c9700618f7 | |
Jef Roosens | 346f39f243 | |
Jef Roosens | 70fc8dd5f4 | |
Jef Roosens | 3c7259ce08 | |
Jef Roosens | 75be358ff6 | |
Jef Roosens | 6f0af5dfdf | |
Jef Roosens | 9c1a56445b | |
Jef Roosens | e41d9780a4 | |
Jef Roosens | 5648f1a9de | |
Jef Roosens | e7637a1bce | |
Jef Roosens | f6f081bfba | |
Jef Roosens | beb5bf49cc | |
Jef Roosens | d1dc46c17b |
|
@ -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; }
|
|
@ -8,8 +8,9 @@ build/
|
|||
*.egg-info/
|
||||
*.eggs/
|
||||
|
||||
# Docs output
|
||||
# Docs
|
||||
docs/build
|
||||
docs/source/apidoc/
|
||||
|
||||
# Caches
|
||||
__pycache__/
|
||||
|
|
|
@ -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
|
||||
|
|
32
Makefile
32
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 ] && { \
|
||||
|
|
37
README.md
37
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)
|
||||
```
|
||||
|
|
|
@ -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)
|
|
@ -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
|
|
@ -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']
|
|
@ -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`
|
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -7,4 +7,4 @@ class DuplicateCommand(Exception):
|
|||
|
||||
|
||||
class MultipleDefaults(Exception):
|
||||
message = 'Multiple default commands detected'
|
||||
message = "Multiple default commands detected"
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -2,5 +2,5 @@ from .help import HelpMod
|
|||
|
||||
|
||||
__all__ = [
|
||||
'HelpMod',
|
||||
"HelpMod",
|
||||
]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
[tool.black]
|
||||
line-length = 79
|
||||
target-version = ['py38']
|
||||
include = '\.pyi?$'
|
||||
exclude = '''
|
||||
|
||||
(
|
||||
/(
|
||||
\.eggs
|
||||
| \.git
|
||||
| venv
|
||||
| build
|
||||
| dist
|
||||
)/
|
||||
)
|
||||
'''
|
|
@ -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
|
||||
|
|
2
setup.py
2
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()]
|
||||
|
|
|
@ -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()
|
Reference in New Issue