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.

19 Commits

Author SHA1 Message Date
Jef Roosens 2a9323b5f5 Unfinished changes 2020-09-15 15:13:22 +02:00
Jef Roosens 32ab8538a4 Unfinished changes, i gotta push 2020-09-04 11:12:22 +02:00
Jef Roosens 7a7bd6fed4 Added black formatter to pre-commit hook 2020-09-01 09:17:09 +02:00
Jef Roosens 6f2d9d1dde Updated daemon info in README 2020-08-31 22:48:14 +02:00
Jef Roosens 593fe189cd Merge branch 'daemon-rework' into develop 2020-08-31 22:38:57 +02:00
Jef Roosens 5855c931ca Added pre-commit hook 2020-08-28 13:15:08 +02:00
Jef Roosens c9700618f7 Merge branch 'sphinx-config' into develop 2020-08-28 12:00:49 +02:00
Jef Roosens 346f39f243 Tidied up Makefile 2020-08-28 12:00:21 +02:00
Jef Roosens 70fc8dd5f4 Remove tar sdist from setup.py 2020-08-28 11:43:55 +02:00
Jef Roosens 3c7259ce08 Added install instructions 2020-08-28 09:38:04 +02:00
Jef Roosens 75be358ff6 apidoc now has its own directory 2020-08-27 18:40:54 +02:00
Jef Roosens 6f0af5dfdf Switched to Read The Docs theme 2020-08-27 18:30:56 +02:00
Jef Roosens 9c1a56445b Docs now auto-generate from docstrings 2020-08-27 18:18:27 +02:00
Jef Roosens e41d9780a4 Generated default Sphinx config 2020-08-27 17:34:08 +02:00
Jef Roosens 5648f1a9de Flattened code a bit 2020-08-27 17:23:38 +02:00
Jef Roosens e7637a1bce Daemon now must be async (more consistent) 2020-08-27 13:06:22 +02:00
Jef Roosens f6f081bfba Daemons now accept an interval value 2020-08-27 11:59:15 +02:00
Jef Roosens beb5bf49cc Added decorator tests; fixed README typo 2020-08-27 09:41:03 +02:00
Jef Roosens d1dc46c17b Added install instructions to README 2020-08-26 17:30:22 +02:00
25 changed files with 586 additions and 123 deletions

2
.flake8 100644
View File

@ -0,0 +1,2 @@
[flake8]
inline-quotes = double

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/ *.egg-info/
*.eggs/ *.eggs/
# Docs output # Docs
docs/build docs/build
docs/source/apidoc/
# Caches # Caches
__pycache__/ __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 ### Added
- Prefix can now be passed as argument to init - Prefix can now be passed as argument to init
- Pre-made help module - Pre-made help module

View File

@ -10,10 +10,10 @@ DOCS=docs
# Interpreter to create venv with # Interpreter to create venv with
INTERPRETER=python3.8 INTERPRETER=python3.8
all: run all: build-venv
# Re-create venv when needed # =====VENV=====
$(VENV)/bin/activate: requirements.txt requirements-dev.txt $(VENV)/bin/activate: requirements.txt requirements-dev.txt
@ echo "Rebuilding venv..." @ echo "Rebuilding venv..."
@ [ ! -e "$(VENV)" ] || rm -rf "$(VENV)" @ [ ! -e "$(VENV)" ] || rm -rf "$(VENV)"
@ -22,8 +22,9 @@ $(VENV)/bin/activate: requirements.txt requirements-dev.txt
build-venv: $(VENV)/bin/activate build-venv: $(VENV)/bin/activate
# =====CLEANING===== # =====CLEANING=====
clean: clean-venv clean-cache clean-docs clean: clean-venv clean-cache clean-docs clean-setup
# Remove venv # Remove venv
clean-venv: clean-venv:
@ -37,24 +38,41 @@ clean-cache:
@ echo "Removing caches..." @ echo "Removing caches..."
@ find . -type d \( -name "__pycache__" -o -name ".pytest_cache" \) -exec rm -r "{}" + @ find . -type d \( -name "__pycache__" -o -name ".pytest_cache" \) -exec rm -r "{}" +
# Removed generation documentation
clean-docs: clean-docs:
@ echo "Removing documentation build..." @ echo "Removing documentation build..."
@ [ ! -e "$(DOCS)/build" ] || rm -r "$(DOCS)/build" @ [ ! -e "$(DOCS)/build" ] || rm -r "$(DOCS)/build"
@ [ ! -e "$(DOCS)/source/apidoc" ] || rm -r "$(DOCS)/source/apidoc"
# Remove build leftovers (not dist)
clean-setup: clean-setup:
@ echo 'Removing build artifacts...' @ echo 'Removing build artifacts...'
@ [ ! -e "build" ] || rm -rf build @ [ ! -e "build" ] || rm -rf build
@ find . -maxdepth 1 -type d -name '*.egg-info' -exec rm -rf "{}" \; @ find . -maxdepth 1 -type d -name '*.egg-info' -exec rm -rf "{}" \;
# =====DOCS===== # =====DOCS=====
docs: build-venv # # Generate documentation
@ "$(VENV)/bin/sphinx-apidoc" -o "$(DOCS)/source" "$(SRC)" 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" @ "$(VENV)/bin/sphinx-build" "$(DOCS)/source" "$(DOCS)/build"
# =====TESTS===== # =====TESTS=====
# Run tests
test: pytest.ini build-venv 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===== # =====PACKAGING=====
@ -62,7 +80,7 @@ package: README.md LICENSE setup.py test clean-setup
@ echo "Removing build..." @ echo "Removing build..."
@ [ ! -e "dist" ] || rm -r "dist" @ [ ! -e "dist" ] || rm -r "dist"
@ echo "Running setup.py..." @ echo "Running setup.py..."
@ "$(VENV)/bin/python" setup.py sdist bdist_wheel @ "$(VENV)/bin/python" setup.py bdist_wheel
publish: package publish: package
@ [ "$$(git symbolic-ref HEAD --short)" = master ] && { \ @ [ "$$(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 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. routing the Discord commands to their respective functions.
## Installation
You can install the `frank-discord` package from PyPi:
```
pip install frank-discord
```
## Example Module ## Example Module
In this section, I've written an example module for you, to understand the basic mechanics behind Frank. 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 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 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, 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 ```python
def pre_start(self): def pre_start(self):
@ -46,14 +53,14 @@ Frank.
# do some stuff # do some stuff
pass pass
@frank.daemon() # Interval defines how many seconds are between each call
@frank.daemon(interval=5)
async def some_daemon(self): async def some_daemon(self):
while True: # do some stuff
# do some stuff pass
pass
@frank.default() @frank.default()
async def default_cmd(self): async def default_cmd(self, author, channel, mid):
# do some default action # do some default action
pass pass
``` ```
@ -66,12 +73,24 @@ These three decorators are the bread and butter of Frank. Let's break them down:
fr examp command [ARGS] 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 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 etc. Each command gets the `author`, `channel`, and `id` of the message. The `cmd` variable contains all the
to the command. 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 - `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 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 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`. 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 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. 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 .frank import Frank
from .module import ( from .module import (
Module, command, Command, default, Default, daemon, Daemon, Module,
regex_command, RegexCommand, command,
Command,
default,
Default,
daemon,
Daemon,
regex_command,
RegexCommand,
) )
__all__ = [ __all__ = [
'Frank', "Frank",
'Module', "Module",
'command', "command",
'Command', "Command",
'default', "default",
'Default', "Default",
'daemon', "daemon",
'Daemon', "Daemon",
'regex_command', "regex_command",
'RegexCommand', "RegexCommand",
] ]

View File

@ -11,6 +11,7 @@ import discord
# Typing imports # Typing imports
from typing import TYPE_CHECKING, List from typing import TYPE_CHECKING, List
if TYPE_CHECKING: if TYPE_CHECKING:
# Own imports # Own imports
from .module import Module from .module import Module
@ -23,8 +24,12 @@ class Frank(discord.Client):
their own behavior. their own behavior.
""" """
def __init__(self, modules: List[Module], config_file: str = 'frank.yaml', def __init__(
prefix: str = 'fr'): self,
modules: List[Module],
config_file: str = "frank.yaml",
prefix: str = "fr",
):
""" """
Args: Args:
modules: modules to load modules: modules to load
@ -39,7 +44,7 @@ class Frank(discord.Client):
self.PREFIX = prefix self.PREFIX = prefix
try: try:
with open(config_file, 'r') as f: with open(config_file, "r") as f:
self._config = yaml.load(f, Loader=yaml.FullLoader) self._config = yaml.load(f, Loader=yaml.FullLoader)
except FileNotFoundError: except FileNotFoundError:
@ -50,7 +55,7 @@ class Frank(discord.Client):
Runs when the bot has succesfully connected to Discord Runs when the bot has succesfully connected to Discord
""" """
print('Connected') print("Connected")
# Startup all modules # Startup all modules
for module in self._modules: for module in self._modules:
@ -63,7 +68,7 @@ class Frank(discord.Client):
await loaded._start() await loaded._start()
self._loaded_modules.append(loaded) self._loaded_modules.append(loaded)
print('All modules loaded') print("All modules loaded")
async def stop(self): async def stop(self):
""" """
@ -88,10 +93,18 @@ class Frank(discord.Client):
except ValueError: except ValueError:
return return
if cmd and cmd[0] == self.PREFIX: # Exit if no commands are given or prefix is wrong
module = next((mod for mod in self._loaded_modules if not (cmd and cmd[0] == self.PREFIX):
if mod.match(cmd[1])), None) return
if module: module = next(
await module(cmd=cmd[2:], author=message.author, (mod for mod in self._loaded_modules if mod.match(cmd[1])), None
channel=message.channel, mid=message.id) )
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 .module import Module
from .decorators import ( from .decorators import (
command, Command, default, Default, daemon, Daemon, regex_command, command,
Command,
default,
Default,
daemon,
Daemon,
regex_command,
RegexCommand, RegexCommand,
) )
__all__ = [ __all__ = [
'Module', "Module",
'command', "command",
'Command', "Command",
'default', "default",
'Default', "Default",
'daemon', "daemon",
'Daemon', "Daemon",
'regex_command', "regex_command",
'RegexCommand', "RegexCommand",
] ]

View File

@ -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 from .functions import command, regex_command, daemon, default
__all__ = [ __all__ = [
'command', "command",
'Command', "RegularCommand",
'regex_command', "regex_command",
'RegexCommand', "RegexCommand",
'default', "default",
'Default', "Default",
'daemon', "daemon",
'Daemon', "Daemon",
] ]

View File

@ -4,6 +4,15 @@ from __future__ import annotations
# Built-in imports # Built-in imports
import re 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: class Simple:
@ -45,10 +54,18 @@ class Simple:
return self 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): def __init__(self, func: callable, cmd: str, help_str: str = None):
@ -64,47 +81,170 @@ class Command(Simple):
self.cmd = cmd self.cmd = cmd
self.help_str = help_str 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: 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 A subclass of Command that can use a regex pattern instead of a fixed
prefix. 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. Returns wether the regex pattern matches the given prefix.
Args: Args:
prefix: string to match pattern against; Pattern must match entire 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): class Daemon(Simple):
""" """
Represents a daemon. Currently, it's only used as its own type, but writing Represents a daemon a.k.a. a background process.
it this way allows us to easily expand upon its functionality later.
""" """
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): 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. command.
""" """

View File

@ -1,19 +1,37 @@
# =====IMPORTS===== # =====IMPORTS=====
# Future imports
from __future__ import annotations
# Own imports # 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. Converts a method into a command by replacing it with a Command object.
Args: Args:
cmd: keyword used to call this function cmd: keyword used to call this function
help_str: short description of the command 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): def inner(func):
return Command(func, cmd, help_str) return RegularCommand(func, cmd, help_str, aliases)
return inner return inner
@ -33,14 +51,14 @@ def regex_command(pattern: str, help_str: str = None) -> callable:
return inner 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 Converts the method into a Daemon, which will then be run when the module
is started. is started.
""" """
def inner(func): def inner(func):
return Daemon(func) return Daemon(func, interval)
return inner return inner

View File

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

View File

@ -6,10 +6,11 @@ from __future__ import annotations
from functools import cached_property from functools import cached_property
# Own imports # Own imports
from .decorators import Command, Daemon, Default, RegexCommand from .decorators import SimpleCommand, Daemon, Default, RegexCommand
# Typing imports # Typing imports
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
# Built-in imports # Built-in imports
from typing import List, Any from typing import List, Any
@ -18,7 +19,7 @@ if TYPE_CHECKING:
class ModuleMeta: class ModuleMeta:
def _filter_attrs(self, condition: callable[[Any], bool]) -> List[Any]: def _filter_attrs(self, condition: callable[[Any], bool]) -> List[Any]:
# This prevents an infinite loop of getting the attribute # This prevents an infinite loop of getting the attribute
illegal_names = ['commands', 'daemons', 'default'] illegal_names = ["commands", "daemons", "default"]
output = [] output = []
@ -31,13 +32,15 @@ class ModuleMeta:
return output return output
@cached_property @cached_property
def commands(self) -> List[Command]: def commands(self) -> List[SimpleCommand]:
# This also matches RegexCommand objects # This also matches RegexCommand objects
# The sort puts all the RegexCommand objects at the back, making them # The sort puts all the RegexCommand objects at the back,making
# be matched last # them be matched last
return sorted(self._filter_attrs(lambda val: isinstance(val, Command)), return sorted(
key=lambda x: isinstance(x, RegexCommand)) self._filter_attrs(lambda val: isinstance(val, SimpleCommand)),
key=lambda x: isinstance(x, RegexCommand),
)
@cached_property @cached_property
def daemons(self) -> List[Daemon]: def daemons(self) -> List[Daemon]:
@ -45,5 +48,7 @@ class ModuleMeta:
@cached_property @cached_property
def default(self) -> Default: def default(self) -> Default:
return next(iter(self._filter_attrs( return next(
lambda val: isinstance(val, Default))), None) iter(self._filter_attrs(lambda val: isinstance(val, Default))),
None,
)

View File

@ -12,6 +12,7 @@ from .decorators import RegexCommand
# Typing imports # Typing imports
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
# Built-in imports # Built-in imports
from typing import List, Dict from typing import List, Dict
@ -33,13 +34,13 @@ class Module(ModuleMeta):
Prefix to activate this module. Prefix to activate this module.
""" """
NAME = '' NAME = ""
""" """
The name is used in various places, such as the config file and the The name is used in various places, such as the config file and the
help function. help function.
""" """
HELP = '' HELP = ""
""" """
Short description of the module to use in the help function. Short description of the module to use in the help function.
""" """
@ -84,8 +85,9 @@ class Module(ModuleMeta):
for task in self._tasks: for task in self._tasks:
task.cancel() task.cancel()
async def __call__(self, cmd: List[str], author: User, async def __call__(
channel: Messageable, mid: int): self, cmd: List[str], author: User, channel: Messageable, mid: int
):
""" """
Execute the command, if found. Execute the command, if found.
@ -97,21 +99,28 @@ class Module(ModuleMeta):
""" """
if cmd: if cmd:
func = next((func for func in self.commands func = next(
if func.match(cmd[0])), None) (func for func in self.commands if func.match(cmd[0])), None
)
if func: # Throw error if no function is found
# A RegexCommand can use the prefix, as it's not a fixed string if not func:
if isinstance(func, RegexCommand): raise InvalidCommand(f"Unknown command: {cmd}")
await func(prefix=cmd[0], cmd=cmd[1:], author=author,
channel=channel, mid=mid)
else: # A RegexCommand can use the prefix, as it's not a fixed string
await func(cmd=cmd[1:], author=author, channel=channel, if isinstance(func, RegexCommand):
mid=mid) await func(
prefix=cmd[0],
cmd=cmd[1:],
author=author,
channel=channel,
mid=mid,
)
else: else:
raise InvalidCommand(f'Unknown command: {cmd}') await func(
cmd=cmd[1:], author=author, channel=channel, mid=mid
)
elif self.default: elif self.default:
await self.default(author=author, channel=channel, mid=mid) await self.default(author=author, channel=channel, mid=mid)
@ -125,12 +134,12 @@ class Module(ModuleMeta):
prefix: prefix to check prefix: prefix to check
""" """
if cls.PREFIX: # Always return False if there's no PREFIX defined
if isinstance(cls.PREFIX, list): if not cls.PREFIX:
return prefix in cls.PREFIX return False
else: if isinstance(cls.PREFIX, list):
return prefix == cls.PREFIX return prefix in cls.PREFIX
else: else:
return False return prefix == cls.PREFIX

View File

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

View File

@ -10,6 +10,7 @@ from .. import Module, default, regex_command
# Typing imports # Typing imports
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
# Built-in imports # Built-in imports
from typing import List from typing import List
@ -24,11 +25,11 @@ class HelpMod(Module):
other modules. other modules.
""" """
PREFIX = 'help' PREFIX = "help"
NAME = 'help' NAME = "help"
HELP = 'Shows help info about all modules' 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): async def send_all(self, author: User, channel: Messageable, mid: int):
embed = Embed() embed = Embed()
@ -37,20 +38,33 @@ class HelpMod(Module):
await channel.send(embed=embed) await channel.send(embed=embed)
@regex_command(cmd='.+', help_str='Show help about a certain module.') @regex_command(pattern=".+", help_str="Show help about a certain module.")
async def show_module_help(self, prefix: str, cmd: List[str], author: User, async def show_module_help(
channel: Messageable, mid: int): self,
prefix: str,
cmd: List[str],
author: User,
channel: Messageable,
mid: int,
):
# Yes, this command just ignores cmd at the moment # Yes, this command just ignores cmd at the moment
mod_name = prefix.lower() mod_name = prefix.lower()
mod = next((mod for mod in self._client._modules mod = next(
if mod.NAME.lower() == mod_name), None) (
mod
for mod in self._client._modules
if mod.NAME.lower() == mod_name
),
None,
)
if mod: if mod:
embed = Embed() embed = Embed()
if mod.default: if mod.default:
embed.add_field(name='default', value=mod.default.help_str, embed.add_field(
inline=False) name="default", value=mod.default.help_str, inline=False
)
for cmd in mod._COMMANDS: for cmd in mod._COMMANDS:
embed.add_field(name=cmd.cmd, value=mod.help_str, inline=False) embed.add_field(name=cmd.cmd, value=mod.help_str, inline=False)

16
pyproject.toml 100644
View File

@ -0,0 +1,16 @@
[tool.black]
line-length = 79
target-version = ['py38']
include = '\.pyi?$'
exclude = '''
(
/(
\.eggs
| \.git
| venv
| build
| dist
)/
)
'''

View File

@ -11,3 +11,6 @@ setuptools~=49.6.0
pytest>=6.0.1,<7.0.0 pytest>=6.0.1,<7.0.0
pytest-asyncio>=0.14.0,<1.0.0 pytest-asyncio>=0.14.0,<1.0.0
twine>=3.2.0,<4.0.0 twine>=3.2.0,<4.0.0
Sphinx==3.2.1
sphinx-rtd-theme==0.5.0
black

View File

@ -2,6 +2,8 @@
# Third-party imports # Third-party imports
import setuptools import setuptools
# TODO: switch to alternative that uses pyproject.toml (e.g. poetry)
with open('requirements.txt', 'r') as reqs_file: with open('requirements.txt', 'r') as reqs_file:
reqs = [line.strip() for line in reqs_file.readlines()] reqs = [line.strip() for line in reqs_file.readlines()]

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