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/ *.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

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

View File

@ -4,6 +4,14 @@ from __future__ import annotations
# Built-in imports # Built-in imports
import re import re
import asyncio
# Typing imports
from typing import TYPE_CHECKING
if TYPE_CHECKING:
# Built-in imports
from typing import Union
class Simple: class Simple:
@ -87,7 +95,7 @@ class RegexCommand(Command):
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)) return bool(re.fullmatch(self.cmd, prefix))
@ -95,16 +103,38 @@ class RegexCommand(Command):
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,7 +1,17 @@
# =====IMPORTS===== # =====IMPORTS=====
# Future imports
from __future__ import annotations
# Own imports # Own imports
from .classes import Command, RegexCommand, Daemon, Default 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: def command(cmd, help_str: str = None) -> callable:
""" """
@ -33,14 +43,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

@ -10,6 +10,7 @@ from .decorators import Command, 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 = []
@ -36,8 +37,10 @@ class ModuleMeta:
# The sort puts all the RegexCommand objects at the back, making them # The sort puts all the RegexCommand objects at the back, making them
# be matched last # 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, Command)),
key=lambda x: isinstance(x, RegexCommand),
)
@cached_property @cached_property
def daemons(self) -> List[Daemon]: def daemons(self) -> List[Daemon]:
@ -45,5 +48,6 @@ 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,26 @@ 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 +132,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)

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

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