Compare commits

...
This repository has been archived on 2021-03-28. You can view files and clone it, but cannot push or open issues/pull-requests.

17 Commits

22 changed files with 428 additions and 106 deletions

View File

@ -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; }

3
.gitignore vendored
View File

@ -8,8 +8,9 @@ build/
*.egg-info/
*.eggs/
# Docs output
# Docs
docs/build
docs/source/apidoc/
# Caches
__pycache__/

View File

@ -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

View File

@ -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 ] && { \

View File

@ -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)
```

20
docs/Makefile 100644
View File

@ -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)

35
docs/make.bat 100644
View File

@ -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

View File

@ -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']

View File

@ -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`

View File

@ -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",
]

View File

@ -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,
)

View File

@ -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",
]

View File

@ -2,12 +2,12 @@ from .classes import Command, RegexCommand, Daemon, Default
from .functions import command, regex_command, daemon, default
__all__ = [
'command',
'Command',
'regex_command',
'RegexCommand',
'default',
'Default',
'daemon',
'Daemon',
"command",
"Command",
"regex_command",
"RegexCommand",
"default",
"Default",
"daemon",
"Daemon",
]

View File

@ -4,6 +4,14 @@ from __future__ import annotations
# Built-in imports
import re
import asyncio
# Typing imports
from typing import TYPE_CHECKING
if TYPE_CHECKING:
# Built-in imports
from typing import Union
class Simple:
@ -87,7 +95,7 @@ class RegexCommand(Command):
Args:
prefix: string to match pattern against; Pattern must match entire
prefix
prefix
"""
return bool(re.fullmatch(self.cmd, prefix))
@ -95,16 +103,38 @@ class RegexCommand(Command):
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.
"""

View File

@ -1,7 +1,17 @@
# =====IMPORTS=====
# Future imports
from __future__ import annotations
# Own imports
from .classes import Command, RegexCommand, Daemon, Default
# Typing imports
from typing import TYPE_CHECKING
if TYPE_CHECKING:
# Built-in imports
from typing import Union
def command(cmd, help_str: str = None) -> callable:
"""
@ -33,14 +43,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

View File

@ -7,4 +7,4 @@ class DuplicateCommand(Exception):
class MultipleDefaults(Exception):
message = 'Multiple default commands detected'
message = "Multiple default commands detected"

View File

@ -10,6 +10,7 @@ from .decorators import Command, 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 = []
@ -36,8 +37,10 @@ class ModuleMeta:
# 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, Command)),
key=lambda x: isinstance(x, RegexCommand),
)
@cached_property
def daemons(self) -> List[Daemon]:
@ -45,5 +48,6 @@ 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
)

View File

@ -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,26 @@ 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 +132,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

View File

@ -2,5 +2,5 @@ from .help import HelpMod
__all__ = [
'HelpMod',
"HelpMod",
]

View File

@ -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)

View File

@ -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

View File

@ -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()