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
|
||||
__pycache__
|
||||
.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
|
||||
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
|
||||
|
|
77
src/bot.py
77
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('Logged in as {0} ({0.id})'.format(bot.user))
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
print(f"Logged in as {bot.user} ({bot.user.id})")
|
||||
print('------')
|
||||
|
||||
bot.add_cog(Music(bot))
|
||||
bot.add_cog(Music(bot))
|
||||
bot.add_cog(ErrorHandler(bot))
|
||||
|
||||
token = os.environ.get("BOT_TOKEN", None)
|
||||
if not token:
|
||||
print("No token fouund in BOT_TOKEN")
|
||||
exit(1)
|
||||
token = os.environ.get("BOT_TOKEN", None)
|
||||
if not token:
|
||||
print("No token found in BOT_TOKEN")
|
||||
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
|
||||
|
||||
# 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