diff --git a/.gitignore b/.gitignore index 0330071..f106780 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ venv +__pycache__ .env +.coverage diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..e86df46 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,32 @@ +stages: + - test-lint-format + +.job_setup: + image: python:3.9 + before_script: + - pip install -r requirements.txt + +pylint: + extends: + - .job_setup + stage: test-lint-format + script: + - pylint src/ test/ + +autopep8: + extends: + - .job_setup + stage: test-lint-format + script: + - autopep8 --recursive --diff src/ test/ + +pytest: + extends: + - .job_setup + stage: test-lint-format + script: + - pytest --cov=src/ + - coverage xml + artifacts: + reports: + cobertura: coverage.xml diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..6df1145 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,22 @@ +[MASTER] +init-hook="from pylint.config import find_pylintrc; import os, sys; sys.path.append(os.path.dirname(find_pylintrc()) + '/src'); sys.path.append(os.path.dirname(find_pylintrc()) + '/test')" + +[MESSAGES CONTROL] +disable= + missing-module-docstring, + missing-class-docstring, + missing-function-docstring, + global-statement, + too-many-arguments, + too-few-public-methods, + wrong-import-position, + redefined-outer-name, + invalid-name, + no-self-use + +[FORMAT] +indent-string=' ' +max-line-length=160 + +[SIMILARITIES] +ignore-comments = no \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..eea6cb0 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["littlefoxteam.vscode-python-test-adapter"] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b6a65fa --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "pytest", + "type": "python", + "request": "launch", + "module": "pytest" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e52f158 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python.pythonPath": "venv/bin/python3.9", + "python.testing.autoTestDiscoverOnSaveEnabled": true, + "pythonTestExplorer.testFramework": "pytest" +} diff --git a/requirements.txt b/requirements.txt index db3ff52..04e10c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,35 @@ aiohttp==3.7.4.post0 +astroid==2.8.0 async-timeout==3.0.1 attrs==21.2.0 autopep8==1.5.7 cffi==1.14.6 chardet==4.0.0 +coverage==5.5 discord==1.7.3 discord.py==1.7.3 idna==3.2 +iniconfig==1.1.1 +isort==5.9.3 +lazy-object-proxy==1.6.0 +mccabe==0.6.1 multidict==5.1.0 +packaging==21.0 +platformdirs==2.3.0 +pluggy==1.0.0 +py==1.10.0 pycodestyle==2.7.0 pycparser==2.20 +pylint==2.11.1 PyNaCl==1.4.0 +pyparsing==2.4.7 +pytest==6.2.5 +pytest-cov==2.12.1 +pytest-mock==3.6.1 python-dotenv==0.19.0 six==1.16.0 toml==0.10.2 typing-extensions==3.10.0.2 +wrapt==1.12.1 yarl==1.6.3 youtube-dl==2021.6.6 diff --git a/src/bot.py b/src/bot.py index eddb57c..9964019 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,11 +1,11 @@ -import asyncio import os +import sys -import discord from discord.ext import commands from dotenv import load_dotenv from yt import YTDLSource +from error import ErrorHandler load_dotenv() @@ -14,88 +14,43 @@ class Music(commands.Cog): def __init__(self, bot): self.bot = bot - @commands.command() - async def join(self, ctx, *, channel: discord.VoiceChannel): - """Joins a voice channel""" - - if ctx.voice_client is not None: - return await ctx.voice_client.move_to(channel) - - await channel.connect() - - @commands.command() - async def play(self, ctx, *, query): - """Plays a file from the local filesystem""" - - source = discord.PCMVolumeTransformer(discord.FFmpegPCMAudio(query)) - ctx.voice_client.play(source, after=lambda e: print('Player error: %s' % e) if e else None) - - await ctx.send('Now playing: {}'.format(query)) - - @commands.command() - async def yt(self, ctx, *, url): - """Plays from a url (almost anything youtube_dl supports)""" - - async with ctx.typing(): - player = await YTDLSource.from_url(url, loop=self.bot.loop) - ctx.voice_client.play(player, after=lambda e: print('Player error: %s' % e) if e else None) - - await ctx.send('Now playing: {}'.format(player.title)) - @commands.command() async def stream(self, ctx, *, url): - """Streams from a url (same as yt, but doesn't predownload)""" - async with ctx.typing(): player = await YTDLSource.from_url(url, loop=self.bot.loop, stream=True) - ctx.voice_client.play(player, after=lambda e: print('Player error: %s' % e) if e else None) + ctx.voice_client.play(player, after=lambda e: print(f"Player error: {e}") if e else None) - await ctx.send('Now playing: {}'.format(player.title)) - - @commands.command() - async def volume(self, ctx, volume: int): - """Changes the player's volume""" - - if ctx.voice_client is None: - return await ctx.send("Not connected to a voice channel.") - - ctx.voice_client.source.volume = volume / 100 - await ctx.send("Changed volume to {}%".format(volume)) + await ctx.send(f"Now playing: {player.title}") @commands.command() async def stop(self, ctx): - """Stops and disconnects the bot from voice""" - await ctx.voice_client.disconnect() - @play.before_invoke - @yt.before_invoke @stream.before_invoke async def ensure_voice(self, ctx): if ctx.voice_client is None: if ctx.author.voice: await ctx.author.voice.channel.connect() else: - await ctx.send("You are not connected to a voice channel.") raise commands.CommandError("Author not connected to a voice channel.") elif ctx.voice_client.is_playing(): ctx.voice_client.stop() -bot = commands.Bot(command_prefix=commands.when_mentioned_or("!"), - description='Relatively simple music bot example') +if __name__ == "__main__": + bot = commands.Bot(command_prefix=commands.when_mentioned_or("!"), description='Relatively simple music bot example') + @bot.event + async def on_ready(): + print(f"Logged in as {bot.user} ({bot.user.id})") + print('------') -@bot.event -async def on_ready(): - print('Logged in as {0} ({0.id})'.format(bot.user)) - print('------') + bot.add_cog(Music(bot)) + bot.add_cog(ErrorHandler(bot)) -bot.add_cog(Music(bot)) + token = os.environ.get("BOT_TOKEN", None) + if not token: + print("No token found in BOT_TOKEN") + sys.exit(1) -token = os.environ.get("BOT_TOKEN", None) -if not token: - print("No token fouund in BOT_TOKEN") - exit(1) - -bot.run(token) + bot.run(token) diff --git a/src/error.py b/src/error.py new file mode 100644 index 0000000..2b4b457 --- /dev/null +++ b/src/error.py @@ -0,0 +1,25 @@ +from discord.ext import commands + + +class ErrorHandler(commands.Cog): + """A cog for global error handling.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.Cog.listener() + async def on_command_error(self, ctx: commands.Context, error: commands.CommandError): + if isinstance(error, commands.CommandNotFound): + return + if isinstance(error, commands.MissingPermissions): + message = "You are missing the required permissions to run this command!" + elif isinstance(error, commands.UserInputError): + message = "Something about your input was wrong, please check your input and try again!" + else: + message = "Oh no! Something went wrong while running the command!" + + await ctx.send(message) + + +def setup(bot: commands.Bot): + bot.add_cog(ErrorHandler(bot)) diff --git a/src/yt.py b/src/yt.py index 69d9592..8148bba 100644 --- a/src/yt.py +++ b/src/yt.py @@ -1,3 +1,6 @@ +import asyncio + +import discord import youtube_dl # Suppress noise about console usage from errors diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..1fa415c --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,22 @@ +import sys +from pathlib import Path +from unittest.mock import Mock + +import pytest +from pytest_mock import MockerFixture + +# Add source to PATH such that imports within src modules are properly resolved. +SRC_PATH = str(Path(__file__).parent / '..' / 'src') +sys.path.insert(0, SRC_PATH) + + +@pytest.fixture +def bot(mocker: MockerFixture): + bot_mock = mocker.patch('discord.ext.commands.Bot', autospec=True) + bot_mock.loop = Mock() + yield bot_mock + + +@pytest.fixture +def ctx(mocker: MockerFixture): + yield mocker.patch('discord.ext.commands.Context', autospec=True) diff --git a/test/test_bot.py b/test/test_bot.py new file mode 100644 index 0000000..1bbe70d --- /dev/null +++ b/test/test_bot.py @@ -0,0 +1,54 @@ +from unittest.mock import Mock, AsyncMock, patch + +import pytest +from discord.ext import commands + +from bot import Music + + +@pytest.mark.asyncio +async def test_bot_ensure_voice(bot, ctx): + mbot = Music(bot) + + # 1. Inside a voice channel + # 1.1 Does not call stop if no sound is playing + ctx.voice_client.is_playing.return_value = False + await mbot.ensure_voice(ctx) + assert ctx.voice_client.stop.call_count == 0 + ctx.reset_mock(return_value=True) + + # 1.2 Does call stop if sound is playing + ctx.voice_client.is_playing.return_value = True + await mbot.ensure_voice(ctx) + assert ctx.voice_client.stop.call_count == 1 + ctx.reset_mock(return_value=True) + + # 2. Not inside a voice channel + # 2.1 Connects to voice channel of author if possible + ctx.voice_client = None + ctx.author.voice = AsyncMock() + await mbot.ensure_voice(ctx) + assert ctx.author.voice.channel.connect.call_count == 1 + ctx.reset_mock(return_value=True) + + # 2.2 Error if author not inside a channel + ctx.voice_client = None + ctx.author.voice = None + with pytest.raises(commands.CommandError): + await mbot.ensure_voice(ctx) + + +@pytest.mark.asyncio +async def test_bot_stream(bot, ctx): + mbot = Music(bot) + + with patch('bot.YTDLSource', new_callable=AsyncMock) as ytdl_source: + player = Mock() + ytdl_source.from_url.return_value = player + url = 'A Day To Remember - All I Want' + # pylint: disable=too-many-function-args + await mbot.stream(mbot, ctx, url=url) + assert ytdl_source.from_url.await_args.args == (url,) + assert ytdl_source.from_url.await_args.kwargs == {"loop": bot.loop, "stream": True} + assert ctx.voice_client.play.call_args.args == (player,) + assert ctx.send.call_count == 1