Merge branch '4-ci' into 'develop'

Resolve "CI (Continuous Integration)"

Closes #3 and #4

See merge request ekzyis/musicube!5
This commit is contained in:
Ramdip Gill 2021-09-24 21:43:53 +00:00
commit 514c6c15c2
12 changed files with 215 additions and 62 deletions

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
venv venv
__pycache__
.env .env
.coverage

32
.gitlab-ci.yml Normal file
View File

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

22
.pylintrc Normal file
View File

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

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["littlefoxteam.vscode-python-test-adapter"]
}

14
.vscode/launch.json vendored Normal file
View File

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

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"python.pythonPath": "venv/bin/python3.9",
"python.testing.autoTestDiscoverOnSaveEnabled": true,
"pythonTestExplorer.testFramework": "pytest"
}

View File

@ -1,19 +1,35 @@
aiohttp==3.7.4.post0 aiohttp==3.7.4.post0
astroid==2.8.0
async-timeout==3.0.1 async-timeout==3.0.1
attrs==21.2.0 attrs==21.2.0
autopep8==1.5.7 autopep8==1.5.7
cffi==1.14.6 cffi==1.14.6
chardet==4.0.0 chardet==4.0.0
coverage==5.5
discord==1.7.3 discord==1.7.3
discord.py==1.7.3 discord.py==1.7.3
idna==3.2 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 multidict==5.1.0
packaging==21.0
platformdirs==2.3.0
pluggy==1.0.0
py==1.10.0
pycodestyle==2.7.0 pycodestyle==2.7.0
pycparser==2.20 pycparser==2.20
pylint==2.11.1
PyNaCl==1.4.0 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 python-dotenv==0.19.0
six==1.16.0 six==1.16.0
toml==0.10.2 toml==0.10.2
typing-extensions==3.10.0.2 typing-extensions==3.10.0.2
wrapt==1.12.1
yarl==1.6.3 yarl==1.6.3
youtube-dl==2021.6.6 youtube-dl==2021.6.6

View File

@ -1,11 +1,11 @@
import asyncio
import os import os
import sys
import discord
from discord.ext import commands from discord.ext import commands
from dotenv import load_dotenv from dotenv import load_dotenv
from yt import YTDLSource from yt import YTDLSource
from error import ErrorHandler
load_dotenv() load_dotenv()
@ -14,88 +14,43 @@ class Music(commands.Cog):
def __init__(self, bot): def __init__(self, bot):
self.bot = 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() @commands.command()
async def stream(self, ctx, *, url): async def stream(self, ctx, *, url):
"""Streams from a url (same as yt, but doesn't predownload)"""
async with ctx.typing(): async with ctx.typing():
player = await YTDLSource.from_url(url, loop=self.bot.loop, stream=True) 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)) await ctx.send(f"Now playing: {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))
@commands.command() @commands.command()
async def stop(self, ctx): async def stop(self, ctx):
"""Stops and disconnects the bot from voice"""
await ctx.voice_client.disconnect() await ctx.voice_client.disconnect()
@play.before_invoke
@yt.before_invoke
@stream.before_invoke @stream.before_invoke
async def ensure_voice(self, ctx): async def ensure_voice(self, ctx):
if ctx.voice_client is None: if ctx.voice_client is None:
if ctx.author.voice: if ctx.author.voice:
await ctx.author.voice.channel.connect() await ctx.author.voice.channel.connect()
else: else:
await ctx.send("You are not connected to a voice channel.")
raise commands.CommandError("Author not connected to a voice channel.") raise commands.CommandError("Author not connected to a voice channel.")
elif ctx.voice_client.is_playing(): elif ctx.voice_client.is_playing():
ctx.voice_client.stop() ctx.voice_client.stop()
bot = commands.Bot(command_prefix=commands.when_mentioned_or("!"), if __name__ == "__main__":
description='Relatively simple music bot example') bot = commands.Bot(command_prefix=commands.when_mentioned_or("!"), description='Relatively simple music bot example')
@bot.event
@bot.event async def on_ready():
async def on_ready(): print(f"Logged in as {bot.user} ({bot.user.id})")
print('Logged in as {0} ({0.id})'.format(bot.user))
print('------') print('------')
bot.add_cog(Music(bot)) bot.add_cog(Music(bot))
bot.add_cog(ErrorHandler(bot))
token = os.environ.get("BOT_TOKEN", None) token = os.environ.get("BOT_TOKEN", None)
if not token: if not token:
print("No token fouund in BOT_TOKEN") print("No token found in BOT_TOKEN")
exit(1) sys.exit(1)
bot.run(token) bot.run(token)

25
src/error.py Normal file
View File

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

View File

@ -1,3 +1,6 @@
import asyncio
import discord
import youtube_dl import youtube_dl
# Suppress noise about console usage from errors # Suppress noise about console usage from errors

22
test/conftest.py Normal file
View File

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

54
test/test_bot.py Normal file
View File

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