diff --git a/.gitignore b/.gitignore index 99b908f..dc61549 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ __pycache__/ *.py[cod] *$py.class +# vscode environment +.vscode/ + # bat extension *.bat diff --git a/PyDiscoBot/__init__.py b/PyDiscoBot/__init__.py index 46969e9..ef03a43 100644 --- a/PyDiscoBot/__init__.py +++ b/PyDiscoBot/__init__.py @@ -1,23 +1,37 @@ """PyDiscoBot - a bot by irox """ +from . import ( + commands, + tasks, + types, + channels, + const, + frame, + log, + test_pydiscobot +) + + from .bot import Bot -from .types import BotNotLoaded, EmbedField, IllegalChannel, InsufficientPrivilege -from .types import ReportableError, Pagination, InteractionPagination -from .services import const, cmds -from . import embed_frames +from .cog import Cog +from .task import Task +from .types import EmbedField, InteractionPagination -__version__ = "1.1.3" +__version__ = "1.1.4" __all__ = ( - "Bot", - 'ReportableError', - 'IllegalChannel', - 'InsufficientPrivilege', - 'BotNotLoaded', - "Pagination", - "InteractionPagination", + 'commands', + 'tasks', + 'types', + 'channels', 'const', - 'cmds', - 'embed_frames', + 'frame', + 'log', + 'task', + 'test_pydiscobot', + 'Bot', + 'Cog', 'EmbedField', + 'InteractionPagination', + 'Task', ) diff --git a/PyDiscoBot/bot.py b/PyDiscoBot/bot.py index 1dec46e..b733b6c 100644 --- a/PyDiscoBot/bot.py +++ b/PyDiscoBot/bot.py @@ -2,24 +2,35 @@ """ from __future__ import annotations + import asyncio import datetime +from io import StringIO import os -from logging import Logger -from typing import Union -import discord -from discord.ext import commands as disco_commands -from discord import app_commands -from .embed_frames import get_notification -from .services import const, channels -from .services.cmds import Commands -from .services.log import logger -from .tasks.admin import AdminTask -from .types import AdminInfo, IllegalChannel, BotNotLoaded, ReportableError -from .types import InsufficientPrivilege, Tasker +import sys +from typing import Optional, Union +import unittest -class Bot(disco_commands.Bot): +import discord +from discord import app_commands +from discord.ext import commands + +from .const import ERR_BAD_PRV, ERR_RATE_LMT +from .channels import find_ch +from .commands import Commands +from .frame import get_notification_frame +from .log import logger +from .tasks import StatusTask +from .types import ( + Status, + BaseBot, + Tasker, + ReportableError +) + + +class Bot(BaseBot, commands.Bot): """Represents a Discord bot, wrapped with built-in logic This class is a subclass of :class:`discord.ext.commands.Bot` and as a result @@ -42,7 +53,7 @@ class Bot(disco_commands.Bot): command_cogs: list[:class:`discord.ext.commands.Cog`] A list of commands to append to this bot when initializing. During the __init__ call, this list will be appended asyncronously to the bot's cogs. - After modifying what commands a bot has access to, or the parameters of the commands, + After modifying what commands a bot has access to, or the parameters of the commands, a 'sync' command will be required to sync the bot tree. Examples @@ -71,16 +82,15 @@ class Bot(disco_commands.Bot): def __init__(self, command_prefix: Union[str, list], bot_intents: discord.Intents, - command_cogs: list[disco_commands.Cog]): + command_cogs: list[commands.Cog]): + super().__init__(command_prefix=command_prefix, intents=bot_intents) self._logger = logger(self.__class__.__name__) - self.logger.info('initializing...') - - self._admin_info = AdminInfo() + self._status = Status() self._tasker = Tasker() - self._tasker.append(AdminTask(self)) + self._tasker.append(StatusTask(self)) command_cogs.extend(Commands) for cog in command_cogs: @@ -88,110 +98,91 @@ def __init__(self, self.tree.on_error = self.on_tree_error - @property - def admin_info(self) -> AdminInfo: - """ return admin info for the bot - """ - return self._admin_info + async def notify(self, + message: Union[str, Exception]) -> None: + """Send a message to the `Bot`'s notification channel (if exists). - @property - def logger(self) -> Logger: - """get logger of the bot + If there is no notification channel, the `message` is instead printed to console. - Returns: - Logger: logger - """ - return self._logger + .. ------------------------------------------------------------ - @property - def tasker(self) -> Tasker: - """get this bot's task list + Arguments + ----------- + message Union[:class:`str`, :class:`Exception`] + The message to be sent. Will be wrapped in a generic :class:`discord.Embed`. - Returns: - Tasker: task list (Tasker) - """ - return self._tasker - - async def notify(self, - message: str | Exception) -> None: - """ Helper function to send error or notification messages to notify channel with a single parameter.\n - **If a notification channel does not exist**, the notification is printed to console instead\n - **param message**: message to report\n - **returns**: None\n """ if not message: return - if not self._admin_info.channels.notification: - return print(message) - await self.send_notification(ctx=self._admin_info.channels.notification, - text=message, + + if not self.status.channels.notification: + print(message) + return + + await self.send_notification(dest=self.status.channels.notification, + msg=message, as_reply=False) - async def send_notification(self, - ctx: discord.abc.Messageable, - text: str, - as_reply: bool = False, - as_followup: bool = False) -> None: - """ Helper function to send notifications + async def on_ready(self, + suppress_task=False) -> None: + """method called by discord api when the bot connects to the gateway server and is ready for production + + .. ------------------------------------------------------------ + + Arguments + ----------- + suppress_task :type:`bool` + Whether to suppress the periodic task or not. Defaults to `False`. """ - if isinstance(ctx, discord.ext.commands.Context): - if as_reply and ctx.author is not None: - await ctx.reply(embed=get_notification(text)) - else: - await ctx.send(embed=get_notification(text)) + self._logger.info('PyDiscoBot on_ready...') + if self._status.initialized: + self._logger.warning('already initialized!') + return - elif isinstance(ctx, discord.Interaction): - if as_followup: - await ctx.followup.send(embed=get_notification(text)) - else: - try: - return await ctx.response.send_message(embed=get_notification(text)) - except discord.errors.InteractionResponded: - return await ctx.followup.send(embed=get_notification(text)) - - elif isinstance(ctx, discord.TextChannel): - await ctx.send(embed=get_notification(text)) - - async def on_command_error(self, - ctx: discord.ext.commands.Context, - error) -> None: - """ Override of discord.Bot on_command_error - If CommandNotFound, simply reply to the user of the error.\n - If not, raise the error naturally\n - """ - if isinstance(error, discord.ext.commands.errors.CommandNotFound): - await ctx.reply(const.ERR_BAD_CMD) - elif isinstance(error, discord.ext.commands.errors.MissingRequiredArgument): - await ctx.reply(const.ERR_MSG_PARAM) - elif isinstance(error, discord.HTTPException): - await ctx.reply(const.ERR_RATE_LMT) - elif isinstance(error, InsufficientPrivilege): - await ctx.reply(const.ERR_BAD_PRV) - elif isinstance(error, IllegalChannel): - await ctx.reply(const.ERR_BAD_CH) - elif isinstance(error, BotNotLoaded): - await ctx.reply(const.ERR_BOT_NL) - elif isinstance(error, ReportableError): - await ctx.reply(str(error)) - else: - await self.notify(f'Error encountered:\n{str(error)}') - raise error + admin_channel_token = os.getenv('ADMIN_CHANNEL') + notification_channel_token = os.getenv('NOTIFICATION_CHANNEL') + + if admin_channel_token: + self._logger.info('initializing admin channel...') + self._status.channels.admin = find_ch(self.guilds, + admin_channel_token) + if not self._status.channels.admin: + self._logger.warning('admin channel not found...') + + if notification_channel_token: + self._logger.info('initializing notification channel...') + self._status.channels.notification = find_ch(self.guilds, + notification_channel_token) + if not self._status.channels.notification: + self._logger.warning('notification channel not found...') + + self._status.initialized = True + self._logger.info("POST -> %s", datetime.datetime.now().strftime('%c')) + + if not suppress_task: + self._tasker.run.change_interval(seconds=self.status.cycle_time) + self._tasker.run.start() async def on_tree_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError): """override of parent error method - Args: - interaction (discord.Interaction): interaction that caused the error - error (app_commands.AppCommandError): error raised + .. ------------------------------------------------------------ + + Arguments + ----------- + interaction :class:`discord.Interaction` + `interaction` that caused the error. + error :class:`app_commands.AppCommandError` + Error raised. """ if isinstance(error, app_commands.CommandOnCooldown): - await interaction.response.send_message(const.ERR_RATE_LMT) + await interaction.response.send_message(ERR_RATE_LMT) return elif isinstance(error, app_commands.MissingPermissions): - await interaction.response.send_message(const.ERR_BAD_PRV) + await interaction.response.send_message(ERR_BAD_PRV) return elif isinstance(error, discord.app_commands.CommandInvokeError): @@ -204,38 +195,84 @@ async def on_tree_error(self, await self.notify(error) raise error - async def on_ready(self, - suppress_task=False) -> None: - """method called by discord api when the bot connects to the gateway server and is ready for production + async def send_notification(self, + dest: Union[commands.Context, discord.Interaction, discord.TextChannel], + msg: str, + as_reply: Optional[bool] = False, + as_followup: Optional[bool] = False) -> None: + """Send a notification `embed` to a specified `Channel`, `Context` or `Interaction`. + + .. ------------------------------------------------------------ + + Arguments + ----------- + dest Union[:class:`commands.Context`, :class:`discord.Interaction`, :class:`discord.TextChannel`] + The destination to send a notification `Embed` to. + + msg :type:`str` + The message to be sent in the body of the notification `Embed`. + + as_reply Optional[:class:`bool`] + Defaults to ``False``. Set if you want the bot to reply with the message. + + This works with both `Context` and `Interaction`. + + as_followup Optional[:class:`bool`] + Defaults to ``False``. Set if you want the bot to followup to a defer. + + This ``only`` works with an `Interaction` that has been deferred. + + Otherwise, this WILL raise an `Exception` - Args: - suppress_task (bool, optional): run periodic task or not. Defaults to False. """ - self._logger.info('PyDiscoBot on_ready...') - if self._admin_info.initialized: - self._logger.warning('already initialized!') - return + embed = get_notification_frame(msg) - admin_channel_token = os.getenv('ADMIN_CHANNEL') - notification_channel_token = os.getenv('NOTIFICATION_CHANNEL') + if isinstance(dest, commands.Context): + if as_reply and dest.author is not None: + await dest.reply(embed=embed) + else: + await dest.send(embed=embed) - if admin_channel_token: - self._logger.info('initializing admin channel...') - self._admin_info.channels.admin = channels.find_ch(self.guilds, - admin_channel_token) - if not self._admin_info.channels.admin: - self._logger.warning('admin channel not found...') + elif isinstance(dest, discord.Interaction): + if as_followup: + await dest.followup.send(embed=embed) + else: + try: + await dest.response.send_message(embed=embed) + except discord.errors.InteractionResponded: + await dest.followup.send(embed=embed) - if notification_channel_token: - self._logger.info('initializing notification channel...') - self._admin_info.channels.notification = channels.find_ch(self.guilds, - notification_channel_token) - if not self._admin_info.channels.notification: - self._logger.warning('notification channel not found...') + elif isinstance(dest, discord.TextChannel): + await dest.send(embed=embed) - self._admin_info.initialized = True - self._logger.info("POST -> %s", datetime.datetime.now().strftime('%c')) + else: + raise TypeError( + 'Invalid type was passed.\n' + 'Destination must be of type `Context`, `Interaction` or `TextChannel`.') - if not suppress_task: - self._tasker.run.change_interval(seconds=self.admin_info.cycle_time) - self._tasker.run.start() + +class TestBaseBot(unittest.TestCase): + """test case for `BaseBot` + """ + + def test_notify(self): + """test bot notify + """ + bot = Bot('!', discord.Intents(8), []) + + def notify_callback(**kwargs): + ... + + capt = StringIO() + sys.stdout = capt + + asyncio.run(bot.notify(None)) + self.assertEqual(capt.getvalue(), '') + + value = 'This is a notification!' + asyncio.run(bot.notify(value)) + self.assertEqual(capt.getvalue(), value) + + bot.status.channels.notification = notify_callback + + sys.stdout = sys.__stdout__ diff --git a/PyDiscoBot/services/channels.py b/PyDiscoBot/channels.py similarity index 98% rename from PyDiscoBot/services/channels.py rename to PyDiscoBot/channels.py index c4772d3..dae65a7 100644 --- a/PyDiscoBot/services/channels.py +++ b/PyDiscoBot/channels.py @@ -2,10 +2,17 @@ """ from __future__ import annotations + from typing import Union import discord +__all__ = ( + 'find_ch', + 'clear_messages', +) + + def find_ch(guilds: list[discord.Guild], channel_id: Union[str, int]) -> Union[discord.abc.GuildChannel, None]: """Get a :class:`discord.abc.GuildChannel` with a corresponding `id`, or none if it is not found. diff --git a/PyDiscoBot/cog.py b/PyDiscoBot/cog.py new file mode 100644 index 0000000..7390858 --- /dev/null +++ b/PyDiscoBot/cog.py @@ -0,0 +1,58 @@ +"""Common discord command + all discord commands should be derived from the meta class + """ +from __future__ import annotations + + +from typing import Optional, TYPE_CHECKING + + +from . import types + + +if TYPE_CHECKING: + from .bot import Bot + + +class Cog(types.BaseCog): + """Pseudo 'meta' discord bot `Cog`. + + A :class:`Cog` is a container for discord `commands` and discord `app_commands`. + + Though this is not an :class:`ABC`, this class describes the meta + relationship the cog has within this environment. + + That being said, all commands used for the bot should be + derived from this 'meta' `Cog`. + + .. ------------------------------------------------------------ + + Arguments + ----------- + parent: Optional[:class:`Bot`] + The parent :class:`Bot` of this `Cog`. + + .. ------------------------------------------------------------ + + Attributes + ----------- + parent: :class:`Bot` | None + The parent :class:`Bot` of this `Cog`. + + """ + + def __init__(self, + parent: Optional[Bot] = None): + self._parent: Bot = parent + + @property + def parent(self) -> Optional[Bot]: + """The parent :class:`Bot` of this `Cog`. + + .. ------------------------------------------------------------ + + Returns + ----------- + parent: :class:`Bot` | None + """ + return self._parent diff --git a/PyDiscoBot/services/cmds/__init__.py b/PyDiscoBot/commands/__init__.py similarity index 65% rename from PyDiscoBot/services/cmds/__init__.py rename to PyDiscoBot/commands/__init__.py index dff865f..b68785a 100644 --- a/PyDiscoBot/services/cmds/__init__.py +++ b/PyDiscoBot/commands/__init__.py @@ -5,14 +5,9 @@ from .echo import Echo from .help import Help from .sync import Sync +from .test_commands import TestCommands -__version__ = '1.1.2' - -__all__ = ( - 'Commands', -) - Commands = [ ClearChannel, DateToUnix, @@ -20,3 +15,16 @@ Help, Sync, ] + + +__version__ = '1.1.4' + +__all__ = ( + 'Commands', + 'ClearChannel', + 'DateToUnix', + 'Echo', + 'Help', + 'Sync', + 'TestCommands', +) diff --git a/PyDiscoBot/services/cmds/clearchannel.py b/PyDiscoBot/commands/clearchannel.py similarity index 85% rename from PyDiscoBot/services/cmds/clearchannel.py rename to PyDiscoBot/commands/clearchannel.py index 4247c9f..b0ede45 100644 --- a/PyDiscoBot/services/cmds/clearchannel.py +++ b/PyDiscoBot/commands/clearchannel.py @@ -3,13 +3,18 @@ """ from __future__ import annotations + import discord from discord import app_commands -from pydiscobot.services.channels import clear_messages -from pydiscobot.types import Cmd -class ClearChannel(Cmd): +from .. import ( + channels, + cog +) + + +class ClearChannel(cog.Cog): """ClearChannel command cog. """ @@ -34,4 +39,4 @@ async def clearchannel(self, """ await interaction.response.defer() - await clear_messages(interaction.channel, message_count) + await channels.clear_messages(interaction.channel, message_count) diff --git a/PyDiscoBot/services/cmds/datetounix.py b/PyDiscoBot/commands/datetounix.py similarity index 96% rename from PyDiscoBot/services/cmds/datetounix.py rename to PyDiscoBot/commands/datetounix.py index 9a4d7b2..459cc0e 100644 --- a/PyDiscoBot/services/cmds/datetounix.py +++ b/PyDiscoBot/commands/datetounix.py @@ -3,12 +3,15 @@ """ from __future__ import annotations + import datetime import time import discord from discord import app_commands -from pydiscobot.embed_frames import frame -from pydiscobot.types import Cmd, EmbedField + +from .. import frame +from .. import cog +from ..types import EmbedField ERR = '\n'.join([ @@ -18,7 +21,7 @@ ]) -class DateToUnix(Cmd): +class DateToUnix(cog.Cog): """convert date to unix string date is placed into embed, broken apart, so it can be copied and pasted by the user. """ diff --git a/PyDiscoBot/services/cmds/echo.py b/PyDiscoBot/commands/echo.py similarity index 95% rename from PyDiscoBot/services/cmds/echo.py rename to PyDiscoBot/commands/echo.py index 620793b..59d30c0 100644 --- a/PyDiscoBot/services/cmds/echo.py +++ b/PyDiscoBot/commands/echo.py @@ -4,12 +4,14 @@ """ from __future__ import annotations + import discord from discord import app_commands -from pydiscobot.types import Cmd + +from .. import cog -class Echo(Cmd): +class Echo(cog.Cog): """echo string """ diff --git a/PyDiscoBot/services/cmds/help.py b/PyDiscoBot/commands/help.py similarity index 93% rename from PyDiscoBot/services/cmds/help.py rename to PyDiscoBot/commands/help.py index 041a63b..c39e3ae 100644 --- a/PyDiscoBot/services/cmds/help.py +++ b/PyDiscoBot/commands/help.py @@ -2,15 +2,17 @@ """ from __future__ import annotations + import discord from discord import app_commands -from pydiscobot.embed_frames import frame -from pydiscobot.types import Cmd +from .. import frame +from .. import cog + MSG = '**help**', 'If you have an issue, please reach out to `irox_rl`.' -class Help(Cmd): +class Help(cog.Cog): """help """ diff --git a/PyDiscoBot/services/cmds/sync.py b/PyDiscoBot/commands/sync.py similarity index 97% rename from PyDiscoBot/services/cmds/sync.py rename to PyDiscoBot/commands/sync.py index c83d816..7001be2 100644 --- a/PyDiscoBot/services/cmds/sync.py +++ b/PyDiscoBot/commands/sync.py @@ -4,13 +4,15 @@ """ from __future__ import annotations + import discord from discord import app_commands from discord.ext import commands -from pydiscobot.types import Cmd + +from .. import cog -class Sync(Cmd): +class Sync(cog.Cog): """sync commands """ diff --git a/tests/services/cmds/commands.py b/PyDiscoBot/commands/test_commands.py similarity index 50% rename from tests/services/cmds/commands.py rename to PyDiscoBot/commands/test_commands.py index 212ea5d..1eda722 100644 --- a/tests/services/cmds/commands.py +++ b/PyDiscoBot/commands/test_commands.py @@ -1,11 +1,115 @@ """test commands for pydisco bot """ +from __future__ import annotations + + import asyncio +from typing import Optional import unittest import discord -from tests.types import MockBot -from pydiscobot.types.unittest import MockInteraction -from pydiscobot.services.cmds.datetounix import ERR +from discord.state import ConnectionState +from discord.ext import commands + + +from .clearchannel import ClearChannel +from .datetounix import DateToUnix, ERR +from .echo import Echo +from .help import Help +from .sync import Sync + + +__all__ = ( + 'MockGuild', + 'MockChannel', + 'MockInteractionResponse', + 'MockInteraction', + 'TestCommands', +) + + +class MockGuild(discord.Guild): + """mock Discord Guild + """ + guild_member_count = 69 + guild_name = 'Test Guild' + guild_id = 1234567890 + guild_owner_id = 123123123123 + mock_data = { + 'member_count': guild_member_count, + 'name': guild_name, + 'verification_level': 0, + 'notification_level': 0, + 'id': guild_id, + 'owner_id': guild_owner_id, + } + + def __init__(self): + super().__init__(data=MockGuild.mock_data, state=ConnectionState(dispatch=None, + handlers=None, + hooks=None, + http=None)) + + +class MockChannel(discord.TextChannel): + """mock Discord Text Channel + """ + channel_id = 69420 + channel_name = 'Big Doinkers Anon.' + guild = MockGuild() + state = discord.state.ConnectionState(dispatch=None, + handlers=None, + hooks=None, + http=None) + + def __init__(self, + name: str | None = None, + chid: int | None = None, + guild: discord.Guild | None = None): + data = { + 'id': MockChannel.channel_id if not chid else chid, + 'name': MockChannel.name if not name else name, + 'type': discord.channel.TextChannel, + 'position': 0, + 'guild': MockChannel.guild if not guild else guild + } + super().__init__(state=MockChannel.state, guild=data['guild'], data=data) + + async def delete_messages(self, + *args, + **kwargs): + """dummy method + """ + + async def history(self, + *args, + **kwargs): + """dummy method + """ + for _ in range(0, 0): + yield None + + +class MockInteractionResponse: + """make-shift interaction response to support unit-testing + """ + + def __init__(self, + send_message_cb: Optional[callable] = None): + self.send_message: callable = send_message_cb + + async def defer(self): + """dummy callback for interaction response -> defer + """ + + +class MockInteraction: + """make-shift interaction to support unit-testing + """ + + def __init__(self, + send_message_cb: Optional[callable] = None): + self.channel = MockChannel() + self.response: MockInteractionResponse = MockInteractionResponse(send_message_cb) class TestCommands(unittest.TestCase): @@ -15,19 +119,27 @@ class TestCommands(unittest.TestCase): def test_clearchannel(self): """test clearchannel command """ - bot = MockBot.as_ready() - - cog = bot.cogs['ClearChannel'] + cog = ClearChannel() self.assertIsNotNone(cog) cmd = next((x for x in cog.get_app_commands() if x is not None), None) self.assertIsNotNone(cmd) - # do it + setattr(self, 'msgs_deleted', 0) + + # check it doesn't err + async def test_cb(*_, **__): + setattr(self, 'msgs_deleted', getattr(self, 'msgs_deleted')+1) + + interaction = MockInteraction() + interaction.channel.delete_messages = test_cb + asyncio.run(cmd.callback(self=cmd, - interaction=MockInteraction(), + interaction=interaction, message_count=10)) + self.assertEqual(getattr(self, 'msgs_deleted'), 1) + # check upper level err with self.assertRaises(ValueError) as context: asyncio.run(cmd.callback(self=cmd, @@ -47,9 +159,7 @@ def test_clearchannel(self): def test_datetounix(self): """test datetounix command """ - bot = MockBot.as_ready() - - cog = bot.cogs['DateToUnix'] + cog = DateToUnix() self.assertIsNotNone(cog) cmd = next((x for x in cog.get_app_commands() if x is not None), None) @@ -85,32 +195,27 @@ async def test_callback_fail(embed): def test_echo(self): """test echo command """ - bot = MockBot.as_ready() - - echo_cog = bot.cogs['Echo'] - self.assertIsNotNone(echo_cog) + cog = Echo() + self.assertIsNotNone(cog) - echo_cmd = next((x for x in echo_cog.get_app_commands() if x is not None), None) - self.assertIsNotNone(echo_cmd) + cmd = next((x for x in cog.get_app_commands() if x is not None), None) + self.assertIsNotNone(cmd) sent_value = 'echo these nutz' async def test_callback(message): - bot.logger.info(message) # nice self.assertEqual(sent_value, message) # create dummy interaction # run the command to validate at least an echo works with a built bot - asyncio.run(echo_cmd.callback(self=echo_cmd, - interaction=MockInteraction(test_callback), - message=sent_value)) + asyncio.run(cmd.callback(self=cmd, + interaction=MockInteraction(test_callback), + message=sent_value)) def test_help(self): """test help command """ - bot = MockBot.as_ready() - - cog = bot.cogs['Help'] + cog = Help() self.assertIsNotNone(cog) cmd = next((x for x in cog.get_app_commands() if x is not None), None) @@ -127,9 +232,9 @@ async def test_callback(embed): def test_sync(self): """test sync command """ - bot = MockBot.as_ready() - - cog = bot.cogs['Sync'] + bot = commands.Bot('!', intents=discord.Intents(0)) + cog = Sync(bot) + cog._parent = bot self.assertIsNotNone(cog) cmd = next((x for x in cog.get_app_commands() if x is not None), None) diff --git a/PyDiscoBot/services/const.py b/PyDiscoBot/const.py similarity index 94% rename from PyDiscoBot/services/const.py rename to PyDiscoBot/const.py index bc3d2cf..b59a9b8 100644 --- a/PyDiscoBot/services/const.py +++ b/PyDiscoBot/const.py @@ -1,5 +1,6 @@ """PyDiscoBot constants """ +from __future__ import annotations import os diff --git a/PyDiscoBot/embed_frames/__init__.py b/PyDiscoBot/embed_frames/__init__.py deleted file mode 100644 index 9966ef1..0000000 --- a/PyDiscoBot/embed_frames/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""frames module for discord embeds - """ - -from .frame import get_frame -from .admin import get_admin_frame -from .notification import get_notification - -__version__ = '1.1.2' - -__all__ = ( - 'get_frame', - 'get_admin_frame', - 'get_notification', -) diff --git a/PyDiscoBot/embed_frames/admin.py b/PyDiscoBot/embed_frames/admin.py deleted file mode 100644 index 76220d7..0000000 --- a/PyDiscoBot/embed_frames/admin.py +++ /dev/null @@ -1,51 +0,0 @@ -"""admin embed - """ -from __future__ import annotations - -import discord -from pydiscobot.services.const import DEF_TIME_FORMAT -from pydiscobot.types import AdminInfo, EmbedField -from .frame import get_frame - - -def get_admin_frame(info: AdminInfo) -> discord.Embed: - """Get built-in :class:`discord.Embed` (or 'frame') to display :class:`Bot` :class:`AdminInfo`. - - .. ------------------------------------------------------------ - - Arguments - ----------- - info: :class:`AdminInfo` - The Administrative Information describing it's parent :class:`Bot`. - - .. ------------------------------------------------------------ - - Examples - ---------- - - Get a :class:`discord.Embed` to display a to Admin channel. - - .. code-block:: python - - import discord - from pydiscobot.embed_frames import get_admin_frame - - async def post_admin_info(self, - channel: discord.TextChannel): - '''post admin info to channel''' - - admin_embed = get_admin_frame(self.bot.admin_info) - await channel.send(embed=admin_embed) - - """ - embed = get_frame('**Bot Info**', - 'For help, type `/help`', - [ - EmbedField('Version', f"`{info.version}`"), - EmbedField('Boot Time', f"`{info.boot_time.strftime(DEF_TIME_FORMAT)}`", True), - EmbedField('Current Tick', f"`{info.current_tick}`"), - EmbedField('Last Time', f"`{info.last_time}`"), - EmbedField('Cycle Time', f"`{info.cycle_time}`s", True) - ]) - - return embed diff --git a/PyDiscoBot/embed_frames/notification.py b/PyDiscoBot/embed_frames/notification.py deleted file mode 100644 index ad3d813..0000000 --- a/PyDiscoBot/embed_frames/notification.py +++ /dev/null @@ -1,44 +0,0 @@ -"""notification frame - """ -from __future__ import annotations - -import discord -from pydiscobot.types import EmbedField -from .frame import get_frame - - -def get_notification(text: str) -> discord.Embed: - """Get built-in :class:`discord.Embed` (or 'frame') to display a standard notification. - - .. ------------------------------------------------------------ - - Arguments - ----------- - text: :class:`str` - The message to display in the body of the :class:`discord.Embed`. - - .. ------------------------------------------------------------ - - Examples - ---------- - - Get a :class:`discord.Embed` to display a general notification. - - .. code-block:: python - - import discord - from pydiscobot.embed_frames import get_notification - - async def send_notfication(self, - channel: discord.TextChannel): - '''post notification to channel''' - - embed = get_notification('How are ya doin today?') - - await channel.send(embed=embed) - - """ - embed = get_frame('**Notification**', - None, - [EmbedField('Message', value=text, inline=True)]) - return embed diff --git a/PyDiscoBot/embed_frames/frame.py b/PyDiscoBot/frame.py similarity index 53% rename from PyDiscoBot/embed_frames/frame.py rename to PyDiscoBot/frame.py index c26f5eb..0a626a3 100644 --- a/PyDiscoBot/embed_frames/frame.py +++ b/PyDiscoBot/frame.py @@ -3,11 +3,15 @@ """ from __future__ import annotations + import datetime from typing import Optional, Union +import unittest + import discord -from pydiscobot.services.const import DEF_TIME_FORMAT -from pydiscobot.types import EmbedField + +from . import const +from .types import EmbedField, Status def get_frame(title: Optional[str] = None, @@ -103,7 +107,7 @@ async def send_notification(self, color=discord.Color.from_str(str(color)), title=title, description=descr) - .set_footer(text=f'Generated: {datetime.datetime.now().strftime(DEF_TIME_FORMAT)}') + .set_footer(text=f'Generated: {datetime.datetime.now().strftime(const.DEF_TIME_FORMAT)}') .set_thumbnail(url=thumbnail)) if not fields: @@ -117,3 +121,103 @@ async def send_notification(self, inline=field.inline) return embed + + +def get_status_frame(info: Status) -> discord.Embed: + """Get built-in :class:`discord.Embed` (or 'frame') to display :class:`Bot` :class:`Status`. + + .. ------------------------------------------------------------ + + Arguments + ----------- + info: :class:`Status` + The Information describing it's parent :class:`Bot`. + + .. ------------------------------------------------------------ + + Examples + ---------- + + Get a :class:`discord.Embed` to display a to channel. + + .. code-block:: python + + import discord + from pydiscobot.embed_frames import get_status_frame + + async def post_info(self, + channel: discord.TextChannel): + '''post info to channel''' + + embed = get_status_frame(self.bot.status_info) + await channel.send(embed=embed) + + """ + embed = get_frame('**Bot Info**', + 'For help, type `/help`', + [ + EmbedField('Version', f"`{info.version}`"), + EmbedField('Boot Time', f"`{info.boot_time.strftime(const.DEF_TIME_FORMAT)}`", True), + EmbedField('Current Tick', f"`{info.current_tick}`"), + EmbedField('Last Time', f"`{info.last_time}`"), + EmbedField('Cycle Time', f"`{info.cycle_time}`s", True) + ]) + + return embed + + +def get_notification_frame(text: str) -> discord.Embed: + """Get built-in :class:`discord.Embed` (or 'frame') to display a standard notification. + + .. ------------------------------------------------------------ + + Arguments + ----------- + text: :class:`str` + The message to display in the body of the :class:`discord.Embed`. + + .. ------------------------------------------------------------ + + Examples + ---------- + + Get a :class:`discord.Embed` to display a general notification. + + .. code-block:: python + + import discord + from pydiscobot.embed_frames import get_notification + + async def send_notfication(self, + channel: discord.TextChannel): + '''post notification to channel''' + + embed = get_notification('How are ya doin today?') + + await channel.send(embed=embed) + + """ + embed = get_frame('**Notification**', + None, + [EmbedField('Message', value=text, inline=True)]) + return embed + + +class TestFrames(unittest.TestCase): + """test frames for pydisco bot + """ + + def test_get_frame(self): + """test bot can compile and receive default frame + """ + self.assertTrue(isinstance(get_frame(), discord.Embed)) + + def test_get_status_frame(self): + """test bot can compile and receive status frame + """ + self.assertTrue(isinstance(get_status_frame(Status()), discord.Embed)) + + def test_get_notification_frame(self): + """test bot can compile and receive notification frame + """ + self.assertTrue(isinstance(get_notification_frame('bing bong'), discord.Embed)) diff --git a/PyDiscoBot/services/log.py b/PyDiscoBot/log.py similarity index 100% rename from PyDiscoBot/services/log.py rename to PyDiscoBot/log.py diff --git a/PyDiscoBot/services/__init__.py b/PyDiscoBot/services/__init__.py deleted file mode 100644 index 834589f..0000000 --- a/PyDiscoBot/services/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""PyDiscoBot logical services - commands, channels, constants, loggers, etc. - """ - -from . import cmds, channels, const, log - -__version__ = '1.1.2' - -__all__ = ( - 'cmds', - 'channels', - 'const', - 'log', -) diff --git a/PyDiscoBot/task.py b/PyDiscoBot/task.py new file mode 100644 index 0000000..c1bc27a --- /dev/null +++ b/PyDiscoBot/task.py @@ -0,0 +1,112 @@ +"""provide an abc base class for all tasks to derive from for consistent operations + """ +from __future__ import annotations + + +from logging import Logger +from typing import TYPE_CHECKING + + +from .log import logger +from .types import BaseTask + + +if TYPE_CHECKING: + from .bot import Bot + + +__all__ = ( + 'Task', +) + + +class Task(BaseTask): + """PyDiscoBot :class:`Task` for logical, periodic operations. + + All user tasks should be derived from this :class:`Task`. + + .. ------------------------------------------------------------ + + Attributes + ----------- + logger: :class:`Logger` + The logger for this :class:`Task` + + .. ------------------------------------------------------------ + + Examples + ---------- + + Create a child :type:`class` that inherits this class :class:`Task` + + .. code-block:: python + + import discord + from pydiscobot import Task + + class MyTask(Task): + 'this is my task class!' + + def __init__(self, + parent: pydiscobot.Bot): + super().__init__(parent) + ... + + def run(self): + 'this override gets triggered by the bot every tick!' + ... + + """ + + def __init__(self, + parent: Bot): + super().__init__(parent) + self._logger = logger(self.name) + + @property + def logger(self) -> Logger: + """Get the :class:`pydiscobot.Bot` of this :class:`Task`. + + .. ------------------------------------------------------------ + + Returns + ----------- + parent: :class:`pydiscobot.Bot` + The parent :class:`pydiscobot.Bot` that owns this :class:`Task`. + + """ + return self._logger + + async def run(self): + """Method that is called by :class:`pydiscobot.Bot` during it's ticks. + + A class inheriting this task must override this method. + + .. ------------------------------------------------------------ + + Example + ---------- + + Create a child :type:`class` that inherits this class :class:`Task` + and overrides the :callable:`run` function. + + .. code-block:: python + + import discord + from pydiscobot.types import Task + + class MyTask(Task): + 'this is my task class!' + + def __init__(self, + parent: pydiscobot.Bot): + super().__init__(parent) + ... # initialize the task + + def run(self): + 'this override gets triggered by the bot every tick!' + ... # do some logic + + + """ + raise NotImplementedError() diff --git a/PyDiscoBot/tasks/__init__.py b/PyDiscoBot/tasks/__init__.py index 2a57fe1..711feb5 100644 --- a/PyDiscoBot/tasks/__init__.py +++ b/PyDiscoBot/tasks/__init__.py @@ -1,10 +1,12 @@ """PyDiscoBot built-in tasks """ -from .admin import AdminTask +from .status import StatusTask +from .test_tasks import TestTasks __version__ = '1.1.2' __all__ = ( - 'AdminTask', + 'StatusTask', + 'TestTasks', ) diff --git a/PyDiscoBot/tasks/admin.py b/PyDiscoBot/tasks/status.py similarity index 53% rename from PyDiscoBot/tasks/admin.py rename to PyDiscoBot/tasks/status.py index aac8c31..afe0aa0 100644 --- a/PyDiscoBot/tasks/admin.py +++ b/PyDiscoBot/tasks/status.py @@ -3,27 +3,22 @@ """ from __future__ import annotations -from datetime import datetime -from typing import Optional + +from typing import Optional, TYPE_CHECKING import discord -import pydiscobot -from pydiscobot.types import Task -from pydiscobot.services import channels -from pydiscobot import embed_frames +from .. import channels, frame +from .. import task -class AdminTask(Task): - """Administrative task for :class:`pydiscobot.Bot`. +if TYPE_CHECKING: + from ..types import BaseBot - Manages updating :class:`AdminInfo`. - Also manages posting infos to admin :class:`discord.TextChannel` (if it exists). - .. ------------------------------------------------------------ +class StatusTask(task.Task): + """Status task for :class:`pydiscobot.Bot`. - Arguments - ----------- - parent: :class:`pydiscobot.Bot` - The bot this task belongs to. + Manages updating :class:`Status`. + Also manages posting infos to admin :class:`discord.TextChannel` (if it exists). .. ------------------------------------------------------------ @@ -36,7 +31,7 @@ class AdminTask(Task): """ def __init__(self, - parent: pydiscobot.Bot): + parent: BaseBot): super().__init__(parent) self._msg: Optional[discord.Message] = None @@ -49,35 +44,24 @@ def message(self) -> Optional[discord.Message]: """ async def _msg_ch(self): - if not self.parent.admin_info.channels.admin: + if not self.parent.status.channels.admin: self.logger.warning('no admin channel available...') return if self._msg: try: - await self._msg.edit(embed=embed_frames.get_admin_frame(self.parent.admin_info)) + await self._msg.edit(embed=frame.get_status_frame(self.parent.status)) return except (discord.errors.NotFound, AttributeError, discord.errors.DiscordServerError): self.logger.info('creating new message...') - await channels.clear_messages(self.parent.admin_info.channels.admin, 100) - self._msg = await self.parent.admin_info.channels.admin.send( - embed=embed_frames.get_admin_frame(self.parent.admin_info) + await channels.clear_messages(self.parent.status.channels.admin, 100) + self._msg = await self.parent.status.channels.admin.send( + embed=frame.get_status_frame(self.parent.status) ) - def _time(self): - """ time function - """ - self.parent.admin_info.last_time = datetime.now() - - async def _admin(self): - """ periodic task admin - """ - self.parent.admin_info.current_tick += 1 - self._time() - async def run(self): """run the admin task """ await self._msg_ch() - await self._admin() + self.parent.status.tick() diff --git a/PyDiscoBot/tasks/test_tasks.py b/PyDiscoBot/tasks/test_tasks.py new file mode 100644 index 0000000..99c1b1d --- /dev/null +++ b/PyDiscoBot/tasks/test_tasks.py @@ -0,0 +1,26 @@ +"""test task functionality for pydisco bot + """ +from __future__ import annotations + +import asyncio +import unittest + + +import discord + + +from .. import bot + + +class TestTasks(unittest.TestCase): + """test task functionality for pydisco bot + """ + + def test_admin_task(self): + """test status task by ticking + """ + _bot = bot.Bot('!', discord.Intents(8), []) + self.assertIsNotNone(_bot.tasker.by_name('StatusTask')) + self.assertEqual(_bot.status.current_tick, 0) + asyncio.run(_bot.tasker.by_name('StatusTask').run()) + self.assertEqual(_bot.status.current_tick, 1) diff --git a/PyDiscoBot/test_pydiscobot.py b/PyDiscoBot/test_pydiscobot.py new file mode 100644 index 0000000..698637f --- /dev/null +++ b/PyDiscoBot/test_pydiscobot.py @@ -0,0 +1,34 @@ +"""testing suite for PyDiscoBot + """ +from __future__ import annotations + + +import asyncio +import unittest +import discord + + +from .bot import Bot +from .task import Task + + +__all__ = ( + 'TestPyDiscoBot', +) + + +class TestPyDiscoBot(unittest.TestCase): + """test pydiscobot suite + """ + + def test_task_class(self): + """test task class + """ + bot = Bot('!', discord.Intents(8), []) + task = Task(bot) + self.assertIsNotNone(task) + self.assertEqual(task.name, 'Task') + with self.assertRaises(NotImplementedError) as context: + asyncio.run(task.run()) + + self.assertTrue(isinstance(context.exception, NotImplementedError)) diff --git a/PyDiscoBot/types/__init__.py b/PyDiscoBot/types/__init__.py index d9a433a..d6afe99 100644 --- a/PyDiscoBot/types/__init__.py +++ b/PyDiscoBot/types/__init__.py @@ -1,27 +1,46 @@ """PyDiscoBot built-in types """ -from . import unittest -from .admin_info import AdminInfo -from .cmd import Cmd +from . import ( + bot, + cog, + embed_field, + err, + pagination, + status, + task, + tasker, +) + +from .status import Status +from .bot import BaseBot +from .cog import BaseCog from .embed_field import EmbedField from .err import BotNotLoaded, IllegalChannel, InsufficientPrivilege, ReportableError from .pagination import Pagination, InteractionPagination -from .task import Task +from .task import BaseTask from .tasker import Tasker -__version__ = '1.1.3' +__version__ = '1.1.4' __all__ = ( - 'AdminInfo', - 'BotNotLoaded', - 'Cmd', + 'bot', + 'cog', + 'embed_field', + 'err', + 'pagination', + 'status', + 'task', + 'tasker', + 'Status', + 'BaseBot', + 'BaseCog', 'EmbedField', + 'BotNotLoaded', 'IllegalChannel', 'InsufficientPrivilege', + 'ReportableError', 'Pagination', 'InteractionPagination', - 'ReportableError', - 'Task', + 'BaseTask', 'Tasker', - 'unittest', ) diff --git a/PyDiscoBot/types/admin_channels.py b/PyDiscoBot/types/admin_channels.py deleted file mode 100644 index 7e6d61d..0000000 --- a/PyDiscoBot/types/admin_channels.py +++ /dev/null @@ -1,25 +0,0 @@ -"""bot administrative channels - """ - -from dataclasses import dataclass -from typing import Optional -import discord - - -@dataclass -class AdminChannels: - """Administrative channels dataclass for the parent :class:`Bot`. - - .. ------------------------------------------------------------ - - Attributes - ----------- - admin: Optional[:class:`discord.abc.GuildChannel`] - Administrative channel where admin embed is posted. - - notification: Optional[:class:`discord.abc.GuildChannel`] - Notification channel where infos are posted - - """ - admin: Optional[discord.abc.GuildChannel] = None - notification: Optional[discord.abc.GuildChannel] = None diff --git a/PyDiscoBot/types/admin_info.py b/PyDiscoBot/types/admin_info.py deleted file mode 100644 index db0ea0b..0000000 --- a/PyDiscoBot/types/admin_info.py +++ /dev/null @@ -1,48 +0,0 @@ -"""bot administrative information - """ - -import os -from datetime import datetime -from .admin_channels import AdminChannels - - -class AdminInfo: - """Administrative information for the parent :class:`Bot` - - This data class describes the meta information about the bot. - - .. ------------------------------------------------------------ - - Attributes - ----------- - version: :class:`str` - Current running version the :class:`Bot` booted with. - - boot_time: :class:`datetime.datetime` - Datetime describing the moment the bot initialized this class. - - last_time: :class:`datetime.datetime` - Datetime describing the last time the :class:`Bot` ran it's administrative task. - - cycle_time: :class:`int` - Number of **seconds** between periodic bot ticks. - - current_tick: :class:`int` - Number describing the amount of ticks performed. - - channels: :class:`AdminChannels` - Administrative channels for the :class:`Bot` to use. - - """ - - def __init__(self): - self.version: str = os.getenv('VERSION', 'N/A?') - self.boot_time: datetime = datetime.now() - self.last_time: datetime = datetime.now() - try: - self.cycle_time: int = int(os.getenv('CYCLE_TIME', '600')) - except KeyError: - self.cycle_time: int = 600 # 10 minute default time - self.current_tick: int = 0 - self.channels: AdminChannels = AdminChannels - self.initialized: bool = False diff --git a/PyDiscoBot/types/bot.py b/PyDiscoBot/types/bot.py new file mode 100644 index 0000000..87b2d9c --- /dev/null +++ b/PyDiscoBot/types/bot.py @@ -0,0 +1,75 @@ +"""bot type module + """ +from __future__ import annotations + + +from logging import Logger +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from .status import Status + from .tasker import Tasker + + +__all__ = ( + 'BaseBot', +) + + +class BaseBot: + """Base bot type. + + .. ------------------------------------------------------------ + + Attributes + ----------- + admin_info: :class:`AdminInfo` + Administrative information for the bot, such as `ticks` and `version` + + logger: :class:`Logger` + Logging module for the bot. Displays information to terminal. + + tasker: :class:`Tasker` + Periodic task manager for the bot. Calls periodic tasks on `tick`. + + """ + _status: Status + _logger: Logger + _tasker: Tasker + + @property + def status(self) -> Status: + """ Administrative information for the bot, such as `ticks` and `version` + + .. ------------------------------------------------------------ + + Returns + ----------- + :class:`pydiscobot.types.AdminInfo` + """ + return self._status + + @property + def logger(self) -> Logger: + """Logging module for the bot. Displays information to terminal. + + .. ------------------------------------------------------------ + + Returns + ----------- + :class:`logging.Logger` + """ + return self._logger + + @property + def tasker(self) -> Tasker: + """Periodic task manager for the bot. Calls periodic tasks on `tick`. + + .. ------------------------------------------------------------ + + Returns + ----------- + :class:`pydiscobot.types.Tasker` + """ + return self._tasker diff --git a/PyDiscoBot/types/cmd.py b/PyDiscoBot/types/cmd.py deleted file mode 100644 index 42702f4..0000000 --- a/PyDiscoBot/types/cmd.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Common discord command - all discord commands should be derived from the meta class - """ -from __future__ import annotations - -from discord.ext import commands -import pydiscobot - - -class Cmd(commands.Cog): - """Pseudo 'meta' discord bot `Command`. - - Though this is not an :class:`ABC`, this class describes the meta - relationship the command has within this environment. - - That being said, all commands used for the bot should be - derived from this 'meta' `Command` - - .. ------------------------------------------------------------ - - Arguments - ----------- - parent: :class:`pydiscobot.Bot` - The parent :class:`Bot` of this `Command`. - - .. ------------------------------------------------------------ - - Attributes - ----------- - parent: :class:`pydiscobot.Bot` - The parent :class:`Bot` of this `Command`. - - """ - - def __init__(self, - parent: pydiscobot.Bot): - self._parent = parent - - @property - def parent(self) -> pydiscobot.Bot: - """get parent of this command - - Returns: - bot.Bot: parent bot - """ - return self._parent diff --git a/PyDiscoBot/types/cog.py b/PyDiscoBot/types/cog.py new file mode 100644 index 0000000..c0a6c4c --- /dev/null +++ b/PyDiscoBot/types/cog.py @@ -0,0 +1,59 @@ +"""Common discord command + all discord commands should be derived from the meta class + """ +from __future__ import annotations + +from typing import TYPE_CHECKING + + +from discord.ext import commands + + +if TYPE_CHECKING: + from .bot import BaseBot + + +__all__ = ( + 'BaseCog', +) + + +class BaseCog(commands.Cog): + """Base `Cog` for PyDiscoBot commands. + + A :class:`Cog` is a container for discord `commands` and discord `app_commands`. + + Though this is not an :class:`ABC`, this class describes the meta + relationship the cog has within this environment. + + That being said, all commands used for the bot should be + derived from this 'meta' `Cog`. + + .. ------------------------------------------------------------ + + Arguments + ----------- + parent: :class:`Bot` + The parent :class:`Bot` of this `Cog`. + + .. ------------------------------------------------------------ + + Attributes + ----------- + parent: :class:`Bot` + The parent :class:`Bot` of this `Cog`. + + """ + _parent: BaseBot + + @property + def parent(self) -> BaseBot: + """ Parent container for this cog. + + .. ------------------------------------------------------------ + + Returns + ----------- + :class:`pydiscobot.types.BaseBot` + """ + return self._parent diff --git a/PyDiscoBot/types/embed_field.py b/PyDiscoBot/types/embed_field.py index 2ba2213..3b907b0 100644 --- a/PyDiscoBot/types/embed_field.py +++ b/PyDiscoBot/types/embed_field.py @@ -2,13 +2,39 @@ """ from __future__ import annotations + from dataclasses import dataclass +from typing import Optional + + +__all__ = ( + 'EmbedField', +) @dataclass class EmbedField: - """helper class to create embed fields more easily + """Embed field to inject into a :class:`discord.Embed`. + + .. ------------------------------------------------------------ + + Arguments + ----------- + name: :type:`str` + Name of the field to display. + + value: :type:`str` + Value to display in the field. + + inline: Optional[:type:`bool`] + Defaults to ``False``. + + Whether to display this field in-line with others. + + + .. ------------------------------------------------------------ + """ name: str value: str - inline: bool = False + inline: Optional[bool] = False diff --git a/PyDiscoBot/types/err.py b/PyDiscoBot/types/err.py index 6bab49a..1ad06a1 100644 --- a/PyDiscoBot/types/err.py +++ b/PyDiscoBot/types/err.py @@ -1,26 +1,34 @@ """provide error types for defining raise conditions """ +from __future__ import annotations + from discord.ext import commands +__all__ = ( + 'BotNotLoaded', + 'InsufficientPrivilege', + 'IllegalChannel', + 'ReportableError' +) + + class BotNotLoaded(commands.CheckFailure): """bot has not finished loading yet. """ - pass class InsufficientPrivilege(commands.CheckFailure): """user does not have correct privileges. """ - pass class IllegalChannel(commands.CheckFailure): """you cannot do that action in this channel. """ - pass class ReportableError(Exception): - pass + """reportable error to append text for notifications to + """ diff --git a/PyDiscoBot/types/pagination.py b/PyDiscoBot/types/pagination.py index b5acf74..bf829a5 100644 --- a/PyDiscoBot/types/pagination.py +++ b/PyDiscoBot/types/pagination.py @@ -1,10 +1,20 @@ """create a scrollable / interactable callback display for user interaction """ +from __future__ import annotations + from typing import Callable, Optional + + import discord +__all__ = ( + 'Pagination', + 'InteractionPagination', +) + + class Pagination(discord.ui.View): """interactable page scrolling view """ diff --git a/PyDiscoBot/types/status.py b/PyDiscoBot/types/status.py new file mode 100644 index 0000000..cf10bd5 --- /dev/null +++ b/PyDiscoBot/types/status.py @@ -0,0 +1,162 @@ +"""status module + """ +from __future__ import annotations + + +import os +from datetime import datetime +from typing import Optional + + +import discord + + +__all__ = ( + 'Status', +) + + +class _StatusChannels: + """Status channels for :class:`Status`. + + .. ------------------------------------------------------------ + + Attributes + ----------- + admin: Optional[:class:`discord.abc.GuildChannel`] + Channel where admin embed is posted. + + notification: Optional[:class:`discord.abc.GuildChannel`] + Channel where notifications are posted. + + """ + admin: Optional[discord.abc.GuildChannel] = None + notification: Optional[discord.abc.GuildChannel] = None + + +class Status: + """Status container for the parent :class:`Bot` + + This data class describes the meta information about bot status. + + .. ------------------------------------------------------------ + + Attributes + ----------- + version: :class:`str` + Current running version the :class:`Bot` booted with. + + boot_time: :class:`datetime.datetime` + Datetime describing the moment the bot initialized this class. + + last_time: :class:`datetime.datetime` + Datetime describing the last time the :class:`Bot` ran it's administrative task. + + cycle_time: :class:`int` + Number of **seconds** between periodic bot ticks. + + current_tick: :class:`int` + Number describing the amount of ticks performed. + + channels: :class:`_StatusChannels` + Status channels for the :class:`Bot` to use. + + initialized: :type:'bool' + Bot has been initialized memory. + + """ + + def __init__(self): + self._version: str = os.getenv('VERSION', 'N/A?') + self._boot_time: datetime = datetime.now() + self._last_time: datetime = datetime.now() + self._tick: int = 0 + self._channels: _StatusChannels = _StatusChannels() + self._initialized: bool = False + + try: + self._cycle_time: int = int(os.getenv('CYCLE_TIME', '600')) + except KeyError: + self._cycle_time: int = 600 # 10 minute default time + + @property + def version(self) -> str: + """ Current running version the :class:`Bot` booted with. + + Returns + ----------- + :type:`str` + """ + return self._version + + @property + def boot_time(self) -> datetime: + """ Datetime describing the moment the bot initialized this class. + + Returns + ----------- + :class:`datetime.datetime` + """ + return self._boot_time + + @property + def last_time(self) -> datetime: + """ Datetime describing the last time the :class:`Bot` ran it's administrative task. + + Returns + ----------- + :class:`datetime.datetime` + """ + return self._last_time + + @property + def current_tick(self) -> int: + """ Number describing the amount of ticks performed. + + Returns + ----------- + :type:`int` + """ + return self._tick + + @property + def channels(self) -> _StatusChannels: + """ Status channels for the :class:`Bot` to use. + + Returns + ----------- + :class:`pydiscobot.types.status._StatusChannels` + """ + return self._channels + + @property + def initialized(self) -> bool: + """ Bot has been initialized memory. + + Returns + ----------- + :type:`bool` + """ + return self._initialized + + @initialized.setter + def initialized(self, value) -> None: + self._initialized = value + + @property + def cycle_time(self) -> int: + """ Number of **seconds** between periodic bot ticks. + + Returns + ----------- + :type:`int` + """ + return self._cycle_time + + def tick(self) -> None: + """Increment the `current_tick` by +1 + + Also, updates the last time to `datetime.now()` + """ + self._tick += 1 + self._last_time = datetime.now() diff --git a/PyDiscoBot/types/task.py b/PyDiscoBot/types/task.py index faff4b9..c5db7f5 100644 --- a/PyDiscoBot/types/task.py +++ b/PyDiscoBot/types/task.py @@ -2,17 +2,21 @@ """ from __future__ import annotations -from abc import ABC, abstractmethod -from logging import Logger -from typing import Any -import pydiscobot -from pydiscobot.services import log +from typing import TYPE_CHECKING -class Task(ABC): - """:class:`ABC` abstract class for :class:`Bot` tasks. - All tasks should be derived from this abstract :class:`Task`. +if TYPE_CHECKING: + from .bot import BaseBot + + +__all__ = ( + 'BaseTask', +) + + +class BaseTask: + """Base class for :class:`Task`. .. ------------------------------------------------------------ @@ -33,63 +37,36 @@ class Task(ABC): .. ------------------------------------------------------------ - Examples - ---------- - - Create a child :type:`class` that inherits this class :class:`Task` - - .. code-block:: python - - import discord - from pydiscobot.types import Task - - class MyTask(Task): - 'this is my task class!' - - def __init__(self, - parent: pydiscobot.Bot): - super().__init__(parent) - ... - - def run(self): - 'this override gets triggered by the bot every tick!' - ... - """ def __init__(self, - parent: pydiscobot.Bot): - self._parent = parent - self._logger = log.logger(self.__class__.__name__) + parent: BaseBot): + self._parent: BaseBot = parent @property def name(self) -> str: - """get the name of this task + """Get the `name` of this :class:`Task`. + + .. ------------------------------------------------------------ + + Returns + ----------- + name: :class:`str` + The name of this :class:`Task`. - Returns: - str: name - """ + """ return self.__class__.__name__ @property - def logger(self) -> Logger: - """get this task's logger + def parent(self) -> BaseBot: + """Get the :class:`pydiscobot.Bot` of this :class:`Task`. - Returns: - Logger: logger - """ - return self._logger + .. ------------------------------------------------------------ - @property - def parent(self) -> Any: - """get this task's parent + Returns + ----------- + parent: :class:`pydiscobot.Bot` + The parent :class:`pydiscobot.Bot` that owns this :class:`Task`. - Returns: - Any: parent - """ + """ return self._parent - - @abstractmethod - async def run(self): - """run the task - """ diff --git a/PyDiscoBot/types/tasker.py b/PyDiscoBot/types/tasker.py index 961749d..d058a20 100644 --- a/PyDiscoBot/types/tasker.py +++ b/PyDiscoBot/types/tasker.py @@ -1,11 +1,24 @@ """bot tasker to periodically run assigned tasks """ +from __future__ import annotations + + +import unittest + from discord.ext import tasks -from .task import Task -class Tasker(list[Task]): +from .task import BaseTask + + +__all__ = ( + 'Tasker', + 'TestTasker', +) + + +class Tasker(list[BaseTask]): """task manager for PyDiscoBot """ @@ -13,18 +26,21 @@ def __init__(self, *args): super().__init__(*args) self._task_hash: dict = {} - def append(self, task: Task): + def __contains__(self, item: BaseTask): + return self._task_hash.get(item, None) is not None + + def append(self, task: BaseTask): if task not in self: super().append(task) self._task_hash[task.name] = task def remove(self, - value: Task): + value: BaseTask): self._task_hash.pop(value.name, None) super().remove(value) def by_name(self, - task_name: str) -> Task | None: + task_name: str) -> BaseTask | None: """get Task by name Args: @@ -43,3 +59,31 @@ async def run(self) -> bool: bool: all tasks completed successfully """ _ = [await task.run() for task in self] + + +class TestTasker(unittest.TestCase): + """test tasker operations + """ + + def test_tasker(self): + """tbd + """ + class BingBong(BaseTask): + pass + + class DingDong(BaseTask): + pass + + task_a = BingBong(None) + task_b = DingDong(None) + + tasker = Tasker() + tasker.append(task_a) + self.assertTrue(len(tasker) == 1) + self.assertIsNotNone(tasker.by_name('BingBong')) + tasker.append(task_b) + self.assertTrue(len(tasker) == 2) + self.assertIsNotNone(tasker.by_name('DingDong')) + tasker.append(task_b) + self.assertTrue(len(tasker) == 2) + self.assertIsNotNone(tasker.by_name('DingDong')) diff --git a/PyDiscoBot/types/unittest/__init__.py b/PyDiscoBot/types/unittest/__init__.py deleted file mode 100644 index 5e20057..0000000 --- a/PyDiscoBot/types/unittest/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""PyDiscoBot built-in unittest types -Provide mock data and meta schemes to allow bot to test -Such as MockGuild, MockMessage, MockUser, MockInteraction, etc - """ -from .mock_channel import MockChannel -from .mock_connection_state import MockConnectionState -from .mock_interaction import MockInteraction, MockInteractionResponse -from .mock_user import MockUser, MockUserData - -__version__ = '1.1.3' - -__all__ = ( - 'MockChannel', - 'MockConnectionState', - 'MockInteraction', - 'MockInteractionResponse', - 'MockUser', - 'MockUserData', -) diff --git a/PyDiscoBot/types/unittest/mock_channel.py b/PyDiscoBot/types/unittest/mock_channel.py deleted file mode 100644 index 588aa57..0000000 --- a/PyDiscoBot/types/unittest/mock_channel.py +++ /dev/null @@ -1,41 +0,0 @@ -"""mock Discord Text Channel - """ -import discord -from .mock_guild import MockGuild -from .mock_connection_state import MockConnectionState - - -class MockChannel(discord.TextChannel): - """mock Discord Text Channel - """ - channel_id = 69420 - channel_name = 'Big Doinkers Anon.' - guild = MockGuild() - state = MockConnectionState() - - def __init__(self, - name: str | None = None, - chid: int | None = None, - guild: discord.Guild | None = None): - data = { - 'id': MockChannel.channel_id if not chid else chid, - 'name': MockChannel.name if not name else name, - 'type': discord.channel.TextChannel, - 'position': 0, - 'guild': MockChannel.guild if not guild else guild - } - super().__init__(state=MockChannel.state, guild=data['guild'], data=data) - - async def delete_messages(self, - *args, - **kwargs): - """dummy method - """ - - async def history(self, - *args, - **kwargs): - """dummy method - """ - for _ in range(0, 0): - yield None diff --git a/PyDiscoBot/types/unittest/mock_connection_state.py b/PyDiscoBot/types/unittest/mock_connection_state.py deleted file mode 100644 index 8852fa5..0000000 --- a/PyDiscoBot/types/unittest/mock_connection_state.py +++ /dev/null @@ -1,11 +0,0 @@ -"""mock connection state to be used for unit testing - """ -from discord.state import ConnectionState - - -class MockConnectionState(ConnectionState): - """mock connection state to be used for unit testing - """ - - def __init__(self): - super().__init__(dispatch=None, handlers=None, hooks=None, http=None) diff --git a/PyDiscoBot/types/unittest/mock_guild.py b/PyDiscoBot/types/unittest/mock_guild.py deleted file mode 100644 index d803e9e..0000000 --- a/PyDiscoBot/types/unittest/mock_guild.py +++ /dev/null @@ -1,27 +0,0 @@ -"""mock Discord Guild - """ -import discord -from discord.state import ConnectionState - - -class MockGuild(discord.Guild): - """mock Discord Guild - """ - guild_member_count = 69 - guild_name = 'Test Guild' - guild_id = 1234567890 - guild_owner_id = 123123123123 - mock_data = { - 'member_count': guild_member_count, - 'name': guild_name, - 'verification_level': 0, - 'notification_level': 0, - 'id': guild_id, - 'owner_id': guild_owner_id, - } - - def __init__(self): - super().__init__(data=MockGuild.mock_data, state=ConnectionState(dispatch=None, - handlers=None, - hooks=None, - http=None)) diff --git a/PyDiscoBot/types/unittest/mock_interaction.py b/PyDiscoBot/types/unittest/mock_interaction.py deleted file mode 100644 index 8b2594d..0000000 --- a/PyDiscoBot/types/unittest/mock_interaction.py +++ /dev/null @@ -1,27 +0,0 @@ -"""make-shift interaction datas to support unit testing - """ -from typing import Optional -from .mock_channel import MockChannel - - -class MockInteractionResponse: - """make-shift interaction response to support unit-testing - """ - - def __init__(self, - send_message_cb: Optional[callable] = None): - self.send_message: callable = send_message_cb - - async def defer(self): - """dummy callback for interaction response -> defer - """ - - -class MockInteraction: - """make-shift interaction to support unit-testing - """ - - def __init__(self, - send_message_cb: Optional[callable] = None): - self.channel = MockChannel() - self.response: MockInteractionResponse = MockInteractionResponse(send_message_cb) diff --git a/PyDiscoBot/types/unittest/mock_member.py b/PyDiscoBot/types/unittest/mock_member.py deleted file mode 100644 index a8c8de6..0000000 --- a/PyDiscoBot/types/unittest/mock_member.py +++ /dev/null @@ -1,42 +0,0 @@ -"""mock Discord Member - """ -import discord -from .mock_guild import MockGuild -from .mock_connection_state import MockConnectionState - - -class MockMember(discord.Member): - """mock Discord Member - """ - member_id = 69420 - member_name = 'Big Doinkers Anon.' - guild = MockGuild() - connection = MockConnectionState() - - def __init__(self, - name: str | None = None, - memid: int | None = None, - guild: discord.Guild | None = None): - data = { - 'name': MockMember.member_name if not name else name, - 'guild': MockMember.guild if not guild else guild, - 'roles': [], - 'flags': 0, - 'user': { - 'username': MockMember.member_name if not name else name, - 'discriminator': 'your mom?', - 'avatar': None, - 'global_name': None, - 'id': MockMember.member_id if not memid else memid, - 'bot': False, - 'system': False, - 'mfa_enabled': False, - 'locale': 'en-US', - 'verified': False, - 'email': None, - 'flags': 0, - 'premium_type': 0, - 'public_flags': 0, - } - } - super().__init__(data=data, guild=data['guild'], state=MockMember.connection) diff --git a/PyDiscoBot/types/unittest/mock_message.py b/PyDiscoBot/types/unittest/mock_message.py deleted file mode 100644 index 2498e02..0000000 --- a/PyDiscoBot/types/unittest/mock_message.py +++ /dev/null @@ -1,81 +0,0 @@ -"""mock Discord Message - """ -import datetime -from typing import Optional, TypedDict, Union -from typing_extensions import NotRequired -import discord -from .mock_connection_state import MockConnectionState -from .mock_channel import MockChannel -from .mock_user import MockUserData - - -class MockMessageData(TypedDict): - """mock message data - """ - id: int - author: discord.User - content: str - timestamp: str - edited_timestamp: Optional[str] - tts: bool - mention_everyone: bool - mentions: list[discord.User] - mention_roles: list[int] - attachments: list[discord.Attachment] - embeds: list[discord.Embed] - pinned: bool - type: discord.MessageType - member: NotRequired[discord.Member] - mention_channels: NotRequired[list[dict]] - reactions: NotRequired[list[discord.Reaction]] - nonce: NotRequired[Union[int, str]] - webhook_id: NotRequired[int] - activity: NotRequired[dict] - application: NotRequired[discord.MessageApplication] - application_id: NotRequired[int] - message_reference: NotRequired[discord.MessageReference] - flags: NotRequired[int] - sticker_items: NotRequired[list[discord.StickerItem]] - referenced_message: NotRequired[Optional[discord.Message]] - interaction: NotRequired[discord.MessageInteraction] - components: NotRequired[list[discord.Component]] - position: NotRequired[int] - role_subscription_data: NotRequired[dict] - - @classmethod - def generic(cls): - """get data as generic - - Returns: - dict: data - """ - return cls({ - 'id': 123456, - 'author': MockUserData.generic(), - 'attachments': [], - 'embeds': [], - 'edited_timestamp': datetime.datetime.now().isoformat(), - 'type': discord.MessageType.chat_input_command, - 'pinned': False, - 'mention_everyone': False, - 'tts': False, - 'content': 'this is some content, right?', - }) - - -class MockMessage(discord.Message): - """mock Discord Message - """ - - def __init__(self, *, - state=None, - channel=None, - data: MockMessageData = None): - if not state: - state = MockConnectionState() - if not channel: - channel = MockChannel() - if not data: - data = MockMessageData.generic() - - super().__init__(state=state, channel=channel, data=data) diff --git a/PyDiscoBot/types/unittest/mock_user.py b/PyDiscoBot/types/unittest/mock_user.py deleted file mode 100644 index 36599b6..0000000 --- a/PyDiscoBot/types/unittest/mock_user.py +++ /dev/null @@ -1,59 +0,0 @@ -"""mock Discord User - """ -from typing import Optional, Literal, TypedDict -import discord -from .mock_connection_state import MockConnectionState - - -class MockUserData(TypedDict): - """mock message data - """ - username: Optional[str] - discriminator: Optional[str] - avatar: None - global_name: None - id: int - bot: bool - system: bool - mfa_enabled: bool - locale: str - verified: bool - email: str - flags: int - premium_type: Literal[0, 1, 2] - public_flags: int - - @classmethod - def generic(cls): - """get data as generic - - Returns: - dict: data - """ - return cls({ - 'username': 'beef', - 'discriminator': 'your mom?', - 'avatar': None, - 'global_name': None, - 'id': 696969420, - 'bot': False, - 'system': False, - 'mfa_enabled': False, - 'locale': 'en-US', - 'verified': False, - 'email': None, - 'flags': 0, - 'premium_type': 0, - 'public_flags': 0, - }) - - -class MockUser(discord.User): - """mock Discord User - """ - - def __init__(self, - data: Optional[dict]): - if not data: - data = MockUserData.generic() - super().__init__(state=MockConnectionState(), data=data) diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..be77a9b --- /dev/null +++ b/test.sh @@ -0,0 +1,6 @@ +# change this to your .venv directory +source C:/Users/brian/Documents/vscode/workspaces/MinorLeagueEsports/.venv/Scripts/activate + +pytest + + read -p "Press Enter to continue..." \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index f212ece..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Unit Tests for PyDiscoBot - """ -from . import types -from . import services - - -__version__ = "1.1.3" - -__all__ = ( - 'types', - 'services', -) diff --git a/tests/embed_frames/__init__.py b/tests/embed_frames/__init__.py deleted file mode 100644 index dec4d56..0000000 --- a/tests/embed_frames/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Services Unit Tests for PyDiscoBot - """ -from .frames import TestFrames - - -__version__ = "1.1.3" - -__all__ = ( - 'TestFrames', -) diff --git a/tests/embed_frames/frames.py b/tests/embed_frames/frames.py deleted file mode 100644 index fa16e34..0000000 --- a/tests/embed_frames/frames.py +++ /dev/null @@ -1,26 +0,0 @@ -"""test frames functionality for pydisco bot - """ -import unittest -import discord -from pydiscobot import embed_frames -from pydiscobot.types import AdminInfo - - -class TestFrames(unittest.TestCase): - """test frames for pydisco bot - """ - - def test_get_frame(self): - """test bot can compile and receive default frame - """ - self.assertTrue(isinstance(embed_frames.get_frame(), discord.Embed)) - - def test_get_admin_frame(self): - """test bot can compile and receive admin frame - """ - self.assertTrue(isinstance(embed_frames.get_admin_frame(AdminInfo()), discord.Embed)) - - def test_get_notification_frame(self): - """test bot can compile and receive notification frame - """ - self.assertTrue(isinstance(embed_frames.get_notification('bing bong'), discord.Embed)) diff --git a/tests/services/__init__.py b/tests/services/__init__.py deleted file mode 100644 index 9a34f8b..0000000 --- a/tests/services/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Services Unit Tests for PyDiscoBot - """ -from . import cmds - - -__version__ = "1.1.3" - -__all__ = ( - 'cmds', -) diff --git a/tests/services/cmds/__init__.py b/tests/services/cmds/__init__.py deleted file mode 100644 index d7c87c6..0000000 --- a/tests/services/cmds/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Command Unit Tests for PyDiscoBot - """ -from .commands import TestCommands - - -__version__ = "1.1.3" - -__all__ = ( - 'TestCommands', -) diff --git a/tests/tasks/__init__.py b/tests/tasks/__init__.py deleted file mode 100644 index 3ca0058..0000000 --- a/tests/tasks/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Services Unit Tests for PyDiscoBot - """ -from .tasks import TestTasks - - -__version__ = "1.1.3" - -__all__ = ( - 'TestTasks', -) diff --git a/tests/tasks/tasks.py b/tests/tasks/tasks.py deleted file mode 100644 index 0d9823c..0000000 --- a/tests/tasks/tasks.py +++ /dev/null @@ -1,21 +0,0 @@ -"""test task functionality for pydisco bot - """ -from __future__ import annotations - -import asyncio -import unittest -from tests.types import MockBot - - -class TestTasks(unittest.TestCase): - """test task functionality for pydisco bot - """ - - def test_admin_task(self): - """test admin task by ticking - """ - bot = MockBot.as_ready() - self.assertIsNotNone(bot.tasker.by_name('AdminTask')) - self.assertEqual(bot.admin_info.current_tick, 0) - asyncio.run(bot.tasker.by_name('AdminTask').run()) - self.assertEqual(bot.admin_info.current_tick, 1) diff --git a/tests/types/.testenv b/tests/types/.testenv deleted file mode 100644 index 28638ee..0000000 --- a/tests/types/.testenv +++ /dev/null @@ -1,43 +0,0 @@ -# test.env file for PyDiscoBot testing -# copy this and fill as you need, but this file will not run anything as is -# version edited: 1.1.4 -# -# Version is used for tracking purposes (imagine that) -VERSION='1.1.4' -# -# Cycle time is how often the bot's periodic task runs. This is in seconds -CYCLE_TIME=600 -# -# Discord token is the private API token supplied by Discord's developer portal when the bot is created -# This is a secret token, DO NOT SHARE THIS TOKEN WITH ANYONE! -# The first link describes how to create a bot -# https://discordpy.readthedocs.io/en/stable/discord.html -# The second link takes you to the developer portal in order to make a bot -# https://discord.com/developers/applications -DISCORD_TOKEN=not_a_chance_bozo -# -# *************************************************************** -# Fill the following in per the requirements of your application -# *************************************************************** -# admin channel ID -# The admin channel posts uptime data of the bot. -# This is not required but is recommended. -# To get the Discord ID, enable developer options in Discord and right click on the channel. -# Select "Copy Channel ID" and paste it here -ADMIN_CHANNEL= -# -# notification channel ID -# The channel the bot will post notifications to when necessary. Like, sprocket updates or errors. -# This is not required but is recommended -# To get the Discord ID, enable developer options in Discord and right click on the channel. -# Select "Copy Channel ID" and paste it here -NOTIFICATION_CHANNEL= -# -# server icon -# This is the 'Emoji' ID of the 'Emoji' that would represent your franchise. -# This emoji will appear on certain embedded posts / messages. -# Getting the server icon out of an emoji can be funky. -# Post the emoji, right click and "Open Link" and copy the number from {} below -# https://cdn.discordapp.com/emojis/ {THIS IS THE NUMBER YOU WANT }.webp?sizeblahblahblah -SERVER_ICON = 'https://images-ext-1.discordapp.net/external/8g0PflayqZyQZe8dqTD_wYGPcCpucRWAsnIjV4amZhc/https/i.imgur.com/6anH1sI.png?format=webp&quality=lossless&width=619&height=619' -# diff --git a/tests/types/__init__.py b/tests/types/__init__.py deleted file mode 100644 index 486a15e..0000000 --- a/tests/types/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Types meant ONLY for unit testing, and do not belong in general production code - """ -from .bot import TestBot -from .mock_bot import MockBot - - -__version__ = "1.1.3" - -__all__ = ( - 'TestBot', - 'MockBot', -) diff --git a/tests/types/bot.py b/tests/types/bot.py deleted file mode 100644 index 32b5703..0000000 --- a/tests/types/bot.py +++ /dev/null @@ -1,19 +0,0 @@ -"""test class for pydisco bot - """ -from __future__ import annotations - -import unittest -from .mock_bot import MockBot - - -class TestBot(unittest.TestCase): - """test class for pydisco bot - """ - - def test_build(self): - """test bot build without err - """ - bot = MockBot.as_ready() - self.assertTrue(bot.admin_info.initialized) - self.assertIsNotNone(bot.admin_info.cycle_time) - self.assertTrue(isinstance(bot.admin_info.cycle_time, int)) diff --git a/tests/types/mock_bot.py b/tests/types/mock_bot.py deleted file mode 100644 index 66c9bdb..0000000 --- a/tests/types/mock_bot.py +++ /dev/null @@ -1,52 +0,0 @@ -"""mock Bot - """ -from __future__ import annotations - -import asyncio -import os -from typing import Self -import discord -import dotenv -from pydiscobot.bot import Bot -from pydiscobot.types.unittest.mock_user import MockUserData -from pydiscobot.types.unittest.mock_connection_state import MockConnectionState - - -class MockBot(Bot): - """Mock :class:`Bot` to be used in unittesting - """ - - @classmethod - def as_mock(cls) -> Self: - """get this bot as a generated mock bot - - Returns: - Self: an instance of bot - """ - dir_path = os.path.dirname(os.path.realpath(__file__)) - dotenv.load_dotenv(f'{dir_path}\\.testenv') - intents = discord.Intents(8) - # noinspection PyDunderSlots - intents.guilds = True - # noinspection PyDunderSlots - intents.members = True - # noinspection PyDunderSlots - intents.message_content = True - # noinspection PyDunderSlots - intents.messages = True - bot = cls('!', intents, []) - data = MockUserData.generic() - data['id'] = 12341234 # change this in case any checks on the default user happen against the bot - bot._connection.user = discord.User(state=MockConnectionState(), data=data) - return bot - - @classmethod - def as_ready(cls) -> Self: - """get this mock bot as an already initialized bot - - Returns: - Self: initialized mock bot - """ - x = cls.as_mock() - asyncio.run(x.on_ready(True)) - return x