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:
commit
514c6c15c2
|
@ -1,2 +1,4 @@
|
||||||
venv
|
venv
|
||||||
|
__pycache__
|
||||||
.env
|
.env
|
||||||
|
.coverage
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["littlefoxteam.vscode-python-test-adapter"]
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"python.pythonPath": "venv/bin/python3.9",
|
||||||
|
"python.testing.autoTestDiscoverOnSaveEnabled": true,
|
||||||
|
"pythonTestExplorer.testFramework": "pytest"
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
65
src/bot.py
65
src/bot.py
|
@ -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('Logged in as {0} ({0.id})'.format(bot.user))
|
print(f"Logged in as {bot.user} ({bot.user.id})")
|
||||||
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)
|
||||||
|
|
|
@ -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))
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
|
@ -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
|
Loading…
Reference in New Issue