aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore113
-rw-r--r--.travis.yml17
-rw-r--r--Pipfile3
-rw-r--r--README.md106
-rw-r--r--bot/__init__.py37
-rw-r--r--bot/__main__.py39
-rw-r--r--bot/bot.py23
-rw-r--r--bot/cogs/candy_collection.py69
-rw-r--r--bot/cogs/hacktoberstats.py9
-rw-r--r--bot/cogs/halloweenify.py48
-rw-r--r--bot/cogs/movie.py136
-rw-r--r--bot/cogs/spookyreact.py31
-rw-r--r--bot/cogs/template.py5
-rw-r--r--bot/resources/halloweenify.json82
-rw-r--r--docker/Dockerfile15
-rwxr-xr-xdocker/build.sh18
-rw-r--r--docker/docker-compose.yml11
-rw-r--r--tox.ini2
18 files changed, 716 insertions, 48 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..8f8974f9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,113 @@
+# bot (project-specific)
+log/*
+
+
+
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+
+# jetbrains
+.idea/
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 00000000..ae1d7653
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,17 @@
+language: python
+
+python:
+ - "3.7-dev"
+
+sudo: required
+
+services:
+ - docker
+
+install:
+ - pip install flake8 pipenv salt-pepper
+ - pipenv install --deploy
+
+script:
+ - pipenv run lint
+ - bash docker/build.sh
diff --git a/Pipfile b/Pipfile
index 90f6f45e..a702616f 100644
--- a/Pipfile
+++ b/Pipfile
@@ -18,4 +18,5 @@ name = "pypi"
python_version = "3.7"
[scripts]
-start = "python -m bot" \ No newline at end of file
+start = "python -m bot"
+lint = "flake8 bot"
diff --git a/README.md b/README.md
index 3a1c32ad..27671cd4 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,108 @@
# hacktoberbot
-A community project for Hacktoberfest 2018. A Discord bot primarily designed to help teach Python learners from the PythonDiscord community how to contribute to open source.
+A community project for [Hacktoberfest 2018](https://hacktoberfest.digitalocean.com). A Discord bot primarily designed to help teach Python learners from the PythonDiscord community how to contribute to open source.
You can find our community by going to https://discord.gg/python
+
+## Motivations
+We know it can be difficult to get into the whole open source thing at first. To help out, we've decided to start a little community project during hacktober that you can all choose to contribute to if you're finding the event a little overwhelming, or if you're new to this whole thing and just want someone to hold your hand at first.
+
+## Commands
+
+!repository - Links to this repository
+
+### Git
+!Git - Links to getting started with Git page
+!Git.commit - An example commit command
+
+## Getting started
+
+If you are new to this you will find it far easier using PyCharm:
+
+### With PyCharm
+
+First things first, what is PyCharm?
+PyCharm is a Python IDE(integrated development environment) that is used to make python development quicker and easier overall.
+So now your going to need to download it [here](https://www.jetbrains.com/pycharm/).
+
+#### 1. Fork
+Ok, now you have got PyCharm downloading you are going to want to fork this project and find its git URL. To fork scroll to the top of the page and press this button.
+![](https://i.imgur.com/Saf9pgJ.png)
+Then when you new forked repository loads you are going to want to get the Git Url by clicking the green `clone or download` button and then the copy link button as seen below:
+![](https://i.imgur.com/o6kuQcZ.png)
+#### 2. Clone
+Now that you have done that you are going to want to load up Pycharm and you'll get something like this without the left sidebar:
+![](https://i.imgur.com/xiGERvR.png)
+You going to want to click Check Out from Version `Control->Git` and you'll get a popup like the one below:
+![](https://i.imgur.com/d4U6Iw7.png)
+Now paste your link in, test the connection and hit `clone`. You now have a copy of your repository to work with and it should setup your Pipenv automatically.
+#### 3. Bot
+Now we have setup our repository we need somewhere to test out bot.
+You'll need to make a new discord server:
+![](https://i.imgur.com/49gBlQI.png)
+We need to make the applicaiton for our bot... navigate over to [discordapp.com/developers](https://discordapp.com/developers) and hit new application
+![](https://i.imgur.com/UIeGPju.png)
+Now we have our discord application you'll want to name your bot as below:
+![](https://i.imgur.com/odTWSMV.png)
+To actually make the bot hit `Bot->Add Bot->Yes, do It!` as below:
+![](https://i.imgur.com/frAUbTZ.png)
+Copy that Token and put to somewhere for safe keeping.
+![](https://i.imgur.com/oEpIqND.png)
+Now to add that robot to out new discord server we need to generate an OAuth2 Url to do so navigate to the OAuth2 tab, Scroll to the OAUTH2 URL GENERATOR section, click the `Bot` checkbox in the scope section and finally hit the `administrator` checkbox in the newly formed Bot Permissions section.
+![](https://i.imgur.com/I2XzYPj.png)
+Copy and paste the link into your browser and follow the instructions to add the bot to your server - ensure it is the server you have just created.
+#### 4. Run Configurations
+Go back to PyCharm and you should have something a bit like below, Your going to want to hit the `Add Configuration` button in the top right.
+![](https://i.imgur.com/nLpDfQO.png)
+We are going to want to choose a python config as below:
+![](https://i.imgur.com/9FgCuP1.png)
+The first setting we need to change is script path as below (the start script may have changed from bot.py so be sure to click the right one
+![](https://i.imgur.com/napKLar.png)
+Now we need to add an enviroment variable - what this will do is allow us to set a value without it affact the main repository.
+To do this click the folder icon to the right of the text, then on the new window the plus icon. Now name the var `HACKTOBERBOT_TOKEN` and give the value the token we kept for safe keeping earilier.
+![](https://i.imgur.com/nZFWNaQ.png)
+Now hit apply on that window and your ready to get going!
+#### 5. Git in PyCharm
+As we work on our project we are going to want to make commits. Commits are effectively a list of changes you made to the pervious version. To make one first hit the green tick in the top right
+![](https://i.imgur.com/BCiisvN.png)
+1. Select the files you wish to commit
+2. Write a brief description of what your commit is
+3. See the actual changes you commit does here (you can also turn some of them off or on if you wish)
+4. Hit commit
+
+![](https://i.imgur.com/xA5ga4C.png)
+Now once you have made a few commits and are happy with your changes you are going to want to push them back to your fork.
+There are three ways of doing this.
+1. Using the VSC Menu `VSC->Git->Push`
+2. Using the VSC popup <code>alt-\`->Push</code>
+3. A shortcut: `ctrl+shift+K`
+
+You should get a menu like below:
+1. List of commits
+2. List of changed files
+3. Hit Push to send to fork!
+
+![](https://i.imgur.com/xA5ga4C.png)
+#### 6. Pull Requests (PR or PRs)
+Goto https://github.com/discord-python/hacktoberbot/pulls and the green New Pull Request button!
+![](https://i.imgur.com/fB4a2wQ.png)
+Now you should hit `Compare across forks` then on the third dropdown select your fork (it will be `your username/hacktoberbot`) then hit Create Pull request.
+1[](https://i.imgur.com/N2X9A9v.png)
+Now to tell other people what your PR does
+1. Title - be concise and informative
+2. Description - write what the PR changes as well as what issues it relates to
+3. Hit `Create pull request`
+![](https://i.imgur.com/OjKYdsL.png)
+
+#### 7. Wait & further reading
+At this point your PR will either be accepted or a maintainer might request some changes.
+
+So you can read up some more on [https://try.github.io](Git), [https://www.jetbrains.com/help/pycharm/quick-start-guide.html](PyCharm) or you might want to learn more about Python and discord: [https://discordpy.readthedocs.io/en/rewrite/](discord.py rewrite)
+
+
+### Without PyCharm
+The process above can be completed without PyCharm however it will be necessary to learn how to use Git, Pipenv and Environment variables.
+
+You can find tutorials for the above below:
+- Git: [try.github](http://try.github.io/)
+- Pipenv [Pipenv.readthedocs](https://pipenv.readthedocs.io)
+- Environment Variables: [youtube](https://youtu.be/bEroNNzqlF4?t=27)
diff --git a/bot/__init__.py b/bot/__init__.py
new file mode 100644
index 00000000..c411deb6
--- /dev/null
+++ b/bot/__init__.py
@@ -0,0 +1,37 @@
+import logging.handlers
+import os
+from pathlib import Path
+
+
+# set up logging
+log_dir = Path("bot", "log")
+log_file = log_dir / "hackbot.log"
+os.makedirs(log_dir, exist_ok=True)
+
+# file handler sets up rotating logs every 5 MB
+file_handler = logging.handlers.RotatingFileHandler(
+ log_file, maxBytes=5*(2**20), backupCount=10)
+file_handler.setLevel(logging.DEBUG)
+
+# console handler prints to terminal
+console_handler = logging.StreamHandler()
+console_handler.setLevel(logging.INFO)
+
+# remove old loggers if any
+root = logging.getLogger()
+if root.handlers:
+ for handler in root.handlers:
+ root.removeHandler(handler)
+
+# Silence irrelevant loggers
+logging.getLogger("discord").setLevel(logging.ERROR)
+logging.getLogger("websockets").setLevel(logging.ERROR)
+
+# setup new logging configuration
+logging.basicConfig(
+ format='%(asctime)s - %(name)s %(levelname)s: %(message)s',
+ datefmt="%D %H:%M:%S",
+ level=logging.DEBUG,
+ handlers=[console_handler, file_handler]
+)
+logging.getLogger().info('Logging initialization complete')
diff --git a/bot/__main__.py b/bot/__main__.py
new file mode 100644
index 00000000..ccd69b0b
--- /dev/null
+++ b/bot/__main__.py
@@ -0,0 +1,39 @@
+import logging
+from os import environ
+from pathlib import Path
+from traceback import format_exc
+
+from discord.ext import commands
+
+HACKTOBERBOT_TOKEN = environ.get('HACKTOBERBOT_TOKEN')
+log = logging.getLogger()
+
+if HACKTOBERBOT_TOKEN:
+ token_dl = len(HACKTOBERBOT_TOKEN) // 8
+ log.info(f'Bot token loaded: {HACKTOBERBOT_TOKEN[:token_dl]}...{HACKTOBERBOT_TOKEN[-token_dl:]}')
+else:
+ log.error(f'Bot token not found: {HACKTOBERBOT_TOKEN}')
+
+ghost_unicode = "\N{GHOST}"
+bot = commands.Bot(command_prefix=commands.when_mentioned_or(".", f"{ghost_unicode} ", ghost_unicode))
+
+log.info('Start loading extensions from ./bot/cogs/')
+
+
+if __name__ == '__main__':
+ # Scan for files in the /cogs/ directory and make a list of the file names.
+ cogs = [file.stem for file in Path('bot', 'cogs').glob('*.py')]
+ for extension in cogs:
+ try:
+ bot.load_extension(f'bot.cogs.{extension}')
+ log.info(f'Successfully loaded extension: {extension}')
+ except Exception as e:
+ log.error(f'Failed to load extension {extension}: {repr(e)} {format_exc()}')
+ # print(f'Failed to load extension {extension}.', file=stderr)
+ # print_exc()
+
+log.info(f'Spooky Launch Sequence Initiated...')
+
+bot.run(HACKTOBERBOT_TOKEN)
+
+log.info(f'HackBot has been slain!')
diff --git a/bot/bot.py b/bot/bot.py
deleted file mode 100644
index f2a857bc..00000000
--- a/bot/bot.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from os import environ
-from pathlib import Path
-from sys import stderr
-from traceback import print_exc
-
-from discord.ext import commands
-
-HACKTOBERBOT_TOKEN = environ.get('HACKTOBERBOT_TOKEN')
-
-ghost_unicode = "\N{GHOST}"
-bot = commands.Bot(command_prefix=commands.when_mentioned_or(".", f"{ghost_unicode} ", ghost_unicode))
-
-if __name__ == '__main__':
- # Scan for files in the /cogs/ directory and make a list of the file names.
- cogs = [file.stem for file in Path('cogs').glob('*.py')]
- for extension in cogs:
- try:
- bot.load_extension(f'cogs.{extension}')
- except Exception as e:
- print(f'Failed to load extension {extension}.', file=stderr)
- print_exc()
-
-bot.run(HACKTOBERBOT_TOKEN)
diff --git a/bot/cogs/candy_collection.py b/bot/cogs/candy_collection.py
index 1d470524..63ce9cb6 100644
--- a/bot/cogs/candy_collection.py
+++ b/bot/cogs/candy_collection.py
@@ -5,7 +5,7 @@ import json
import functools
import os
-json_location = os.path.join(os.getcwd(),'resources', 'candy_collection.json')
+json_location = os.path.join(os.getcwd(), 'resources', 'candy_collection.json')
class CandyCollection:
@@ -19,7 +19,7 @@ class CandyCollection:
userid = userinfo['userid']
self.get_candyinfo[userid] = userinfo
- HACKTOBER_CHANNEL_ID = 496432022961520650
+ HACKTOBER_CHANNEL_ID = 498804484324196362
# chance is 1 in x range, so 1 in 20 range would give 5% chance (for add candy)
ADD_CANDY_REACTION_CHANCE = 20 # 5%
ADD_CANDY_EXISTING_REACTION_CHANCE = 10 # 10%
@@ -27,36 +27,44 @@ class CandyCollection:
ADD_SKULL_EXISTING_REACTION_CHANCE = 20 # 5%
async def on_message(self, message):
- """Make sure the user is not a bot and
- the channel is #event-hacktoberfest.
- Because the skull has a lower chance of occurring
- we'll check for that first, and then add respective reactions"""
+ """Randomly adds candy or skull to certain messages"""
+
+ # make sure its a human message
if message.author.bot:
return
+ # ensure it's hacktober channel
if message.channel.id != self.HACKTOBER_CHANNEL_ID:
return
+
+ # do random check for skull first as it has the lower chance
if random.randint(1, self.ADD_SKULL_REACTION_CHANCE) == 1:
d = {"reaction": '\N{SKULL}', "msg_id": message.id, "won": False}
self.msg_reacted.append(d)
return await message.add_reaction('\N{SKULL}')
+ # check for the candy chance next
if random.randint(1, self.ADD_CANDY_REACTION_CHANCE) == 1:
d = {"reaction": '\N{CANDY}', "msg_id": message.id, "won": False}
self.msg_reacted.append(d)
return await message.add_reaction('\N{CANDY}')
async def on_reaction_add(self, reaction, user):
- """Make sure the reaction is in #event-hacktoberfest
- and the user reacting is not a bot (ie. ourselves)
- Check if the reaction is a skull/candy first. """
+ """Add/remove candies from a person if the reaction satisfies criteria"""
+
message = reaction.message
- if message.channel.id != self.HACKTOBER_CHANNEL_ID:
- return
+ # check to ensure the reactor is human
if user.bot:
return
+ # check to ensure it is in correct channel
+ if message.channel.id != self.HACKTOBER_CHANNEL_ID:
+ return
+
+ # if its not a candy or skull, and it is one of 10 most recent messages,
+ # proceed to add a skull/candy with higher chance
if str(reaction.emoji) not in ('\N{SKULL}', '\N{CANDY}'):
if message.id in await self.ten_recent_msg():
await self.reacted_msg_chance(message)
return
+
for react in self.msg_reacted:
# check to see if the message id of a message we added a
# reaction to is in json file, and if nobody has won/claimed it yet
@@ -78,7 +86,7 @@ class CandyCollection:
await self.send_spook_msg(message.author, message.channel, lost)
except KeyError:
- # otherwise it will raise KeyError so we need to add them
+ # otherwise it will raise KeyError so we need to add them to file
if str(reaction.emoji) == '\N{CANDY}':
print('ok')
d = {"userid": user.id, "record": 1}
@@ -86,8 +94,9 @@ class CandyCollection:
await self.remove_reactions(reaction)
async def reacted_msg_chance(self, message):
- """(Randomly) add a skull or candy to a message if there is a reaction there already
+ """Randomly add a skull or candy to a message if there is a reaction there already
(higher probability)"""
+
if random.randint(1, self.ADD_SKULL_EXISTING_REACTION_CHANCE) == 1:
d = {"reaction": '\N{SKULL}', "msg_id": message.id, "won": False}
self.msg_reacted.append(d)
@@ -101,58 +110,75 @@ class CandyCollection:
async def ten_recent_msg(self):
"""Get the last 10 messages sent in the channel"""
ten_recent = []
- recent_msg = max((x for x in self.bot._connection._messages
- if x.channel.id == self.HACKTOBER_CHANNEL_ID), key=lambda x: x.id)
+ recent_msg = max(message.id for message
+ in self.bot._connection._messages
+ if message.channel.id == self.HACKTOBER_CHANNEL_ID)
+
channel = await self.hacktober_channel()
ten_recent.append(recent_msg.id)
+
for i in range(9):
o = discord.Object(id=recent_msg.id + i)
msg = await channel.history(limit=1, before=o).next()
ten_recent.append(msg.id)
+
return ten_recent
async def get_message(self, msg_id):
- """Get the message from it's ID. Use history rather than get_message due to
- poor ratelimit (50/1s vs 1/1s)"""
+ """Get the message from it's ID."""
+
try:
o = discord.Object(id=msg_id + 1)
+ # Use history rather than get_message due to
+ # poor ratelimit (50/1s vs 1/1s)
msg = await self.hacktober_channel.history(limit=1, before=o).next()
+
if msg.id != msg_id:
return None
+
return msg
+
except Exception:
return None
async def hacktober_channel(self):
- """Get #events-hacktober channel from it's id"""
+ """Get #hacktoberbot channel from it's id"""
return self.bot.get_channel(id=self.HACKTOBER_CHANNEL_ID)
async def remove_reactions(self, reaction):
"""Remove all candy/skull reactions"""
+
try:
async for user in reaction.users():
await reaction.message.remove_reaction(reaction.emoji, user)
+
except discord.HTTPException:
pass
async def send_spook_msg(self, author, channel, candies):
+<<<<<<< HEAD
+ """Send a spooky message"""
+=======
"""Send a (lame) spooky message"""
+>>>>>>> 4c29c2b470d6f27416a97c3be1e41f753e0a1ea6
e = discord.Embed(colour=author.colour)
e.set_author(name="Ghosts and Ghouls and Jack o' lanterns at night; "
f"I took {candies} candies and quickly took flight.")
await channel.send(embed=e)
def save_to_json(self):
- """Save json to the file. We will do this with bad practice
- async (run_in_executor) to prevent blocking"""
+ """Save json to the file."""
with open(json_location, 'w') as outfile:
json.dump(self.candy_json, outfile)
@commands.command()
async def candy(self, ctx):
"""Get the candy leaderboard and save to json when this is called"""
+
+ # use run_in_executor to prevent blocking
thing = functools.partial(self.save_to_json)
save = await self.bot.loop.run_in_executor(None, thing)
+
emoji = (
'\N{FIRST PLACE MEDAL}',
'\N{SECOND PLACE MEDAL}',
@@ -160,8 +186,10 @@ class CandyCollection:
'\N{SPORTS MEDAL}',
'\N{SPORTS MEDAL}'
)
+
top_sorted = sorted(self.candy_json['records'], key=lambda k: k.get('record', 0), reverse=True)
top_five = top_sorted[:5]
+
usersid = []
records = []
for record in top_five:
@@ -170,6 +198,7 @@ class CandyCollection:
value = '\n'.join(f'{emoji[index]} <@{usersid[index]}>: {records[index]}'
for index in range(0, len(usersid))) or 'No Candies'
+
e = discord.Embed(colour=discord.Colour.blurple())
e.add_field(name="Top Candy Records", value=value, inline=False)
e.add_field(name='\u200b',
diff --git a/bot/cogs/hacktoberstats.py b/bot/cogs/hacktoberstats.py
index 4e896ae9..ac81b887 100644
--- a/bot/cogs/hacktoberstats.py
+++ b/bot/cogs/hacktoberstats.py
@@ -95,7 +95,14 @@ class Stats:
is_query = f"public+author:{username}"
date_range = "2018-10-01..2018-10-31"
per_page = "300"
- query_url = f"{base_url}-label:{not_label}+type:{action_type}+is:{is_query}+created:{date_range}&per_page={per_page}"
+ query_url = (
+ f"{base_url}"
+ f"-label:{not_label}"
+ f"+type:{action_type}"
+ f"+is:{is_query}"
+ f"+created:{date_range}"
+ f"&per_page={per_page}"
+ )
headers = {"user-agent": "Discord Python Hactoberbot"}
async with aiohttp.ClientSession() as session:
diff --git a/bot/cogs/halloweenify.py b/bot/cogs/halloweenify.py
new file mode 100644
index 00000000..a5fe45ef
--- /dev/null
+++ b/bot/cogs/halloweenify.py
@@ -0,0 +1,48 @@
+from json import load
+from pathlib import Path
+from random import choice
+
+import discord
+from discord.ext import commands
+from discord.ext.commands.cooldowns import BucketType
+
+
+class Halloweenify:
+ """
+ A cog to change a invokers nickname to a spooky one!
+ """
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ @commands.cooldown(1, 300, BucketType.user)
+ @commands.command()
+ async def halloweenify(self, ctx):
+ """
+ Change your nickname into a much spookier one!
+ """
+ with open(Path('./bot/resources', 'halloweenify.json'), 'r') as f:
+ data = load(f)
+
+ # Choose a random character from our list we loaded above and set apart the nickname and image url.
+ character = choice(data['characters'])
+ nickname = ''.join([nickname for nickname in character])
+ image = ''.join([character[nickname] for nickname in character])
+
+ # Build up a Embed
+ embed = discord.Embed()
+ embed.colour = discord.Colour.dark_orange()
+ embed.title = 'Not spooky enough?'
+ embed.description = (
+ f'**{ctx.author.display_name}** wasn\'t spooky enough for you? That\'s understandable, '
+ f'{ctx.author.display_name} isn\'t scary at all! Let me think of something better. Hmm... I got it!\n\n '
+ f'Your new nickname will be: \n :ghost: **{nickname}** :jack_o_lantern:'
+ )
+ embed.set_image(url=image)
+
+ await ctx.author.edit(nick=nickname)
+ await ctx.send(embed=embed)
+
+
+def setup(bot):
+ bot.add_cog(Halloweenify(bot))
diff --git a/bot/cogs/movie.py b/bot/cogs/movie.py
new file mode 100644
index 00000000..925f813f
--- /dev/null
+++ b/bot/cogs/movie.py
@@ -0,0 +1,136 @@
+import random
+from os import environ
+
+import aiohttp
+from discord import Embed
+from discord.ext import commands
+
+
+TMDB_API_KEY = environ.get('TMDB_API_KEY')
+TMDB_TOKEN = environ.get('TMDB_TOKEN')
+
+
+class Movie:
+ """
+ Selects a random scary movie and embeds info into discord chat
+ """
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ @commands.command(name='movie', alias=['tmdb'])
+ async def random_movie(self, ctx):
+ """
+ Randomly select a scary movie and display information about it.
+ """
+ selection = await self.select_movie()
+ movie_details = await self.format_metadata(selection)
+
+ await ctx.send(embed=movie_details)
+
+ @staticmethod
+ async def select_movie():
+ """
+ Selects a random movie and returns a json of movie details from TMDb
+ """
+
+ url = 'https://api.themoviedb.org/4/discover/movie'
+ params = {
+ 'with_genres': '27',
+ 'vote_count.gte': '5'
+ }
+ headers = {
+ 'Authorization': 'Bearer ' + TMDB_TOKEN,
+ 'Content-Type': 'application/json;charset=utf-8'
+ }
+
+ # Get total page count of horror movies
+ async with aiohttp.ClientSession() as session:
+ response = await session.get(url=url, params=params, headers=headers)
+ total_pages = await response.json()
+ total_pages = total_pages.get('total_pages')
+
+ # Get movie details from one random result on a random page
+ params['page'] = random.randint(1, total_pages)
+ response = await session.get(url=url, params=params, headers=headers)
+ response = await response.json()
+ selection_id = random.choice(response.get('results')).get('id')
+
+ # Get full details and credits
+ selection = await session.get(
+ url='https://api.themoviedb.org/3/movie/' + str(selection_id),
+ params={'api_key': TMDB_API_KEY, 'append_to_response': 'credits'}
+ )
+
+ return await selection.json()
+
+ @staticmethod
+ async def format_metadata(movie):
+ """
+ Formats raw TMDb data to be embedded in discord chat
+ """
+
+ # Build the relevant URLs.
+ movie_id = movie.get("id")
+ poster_path = movie.get("poster_path")
+ tmdb_url = f'https://www.themoviedb.org/movie/{movie_id}' if movie_id else None
+ poster = f'https://image.tmdb.org/t/p/original{poster_path}' if poster_path else None
+
+ # Get cast names
+ cast = []
+ for actor in movie.get('credits', {}).get('cast', [])[:3]:
+ cast.append(actor.get('name'))
+
+ # Get director name
+ director = movie.get('credits', {}).get('crew', [])
+ if director:
+ director = director[0].get('name')
+
+ # Determine the spookiness rating
+ rating = ''
+ rating_count = movie.get('vote_average', 0)
+
+ if rating_count:
+ rating_count /= 2
+
+ for _ in range(int(rating_count)):
+ rating += ':skull:'
+ if (rating_count % 1) >= .5:
+ rating += ':bat:'
+
+ # Try to get year of release and runtime
+ year = movie.get('release_date', [])[:4]
+ runtime = movie.get('runtime')
+ runtime = f"{runtime} minutes" if runtime else None
+
+ # Not all these attributes will always be present
+ movie_attributes = {
+ "Directed by": director,
+ "Starring": ', '.join(cast),
+ "Running time": runtime,
+ "Release year": year,
+ "Spookiness rating": rating,
+ }
+
+ embed = Embed(
+ colour=0x01d277,
+ title='**' + movie.get('title') + '**',
+ url=tmdb_url,
+ description=movie.get('overview')
+ )
+
+ if poster:
+ embed.set_image(url=poster)
+
+ # Add the attributes that we actually have data for, but not the others.
+ for name, value in movie_attributes.items():
+ if value:
+ embed.add_field(name=name, value=value)
+
+ embed.set_footer(text='powered by themoviedb.org')
+
+ return embed
+
+
+def setup(bot):
+ bot.add_cog(Movie(bot))
diff --git a/bot/cogs/spookyreact.py b/bot/cogs/spookyreact.py
new file mode 100644
index 00000000..2652a60e
--- /dev/null
+++ b/bot/cogs/spookyreact.py
@@ -0,0 +1,31 @@
+SPOOKY_TRIGGERS = {
+ 'spooky': "\U0001F47B",
+ 'skeleton': "\U0001F480",
+ 'doot': "\U0001F480",
+ 'pumpkin': "\U0001F383",
+ 'halloween': "\U0001F383",
+ 'jack-o-lantern': "\U0001F383",
+ 'danger': "\U00002620"
+}
+
+
+class SpookyReact:
+
+ """
+ A cog that makes the bot react to message triggers.
+ """
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ async def on_message(self, ctx):
+ """
+ A command to send the hacktoberbot github project
+ """
+ for trigger in SPOOKY_TRIGGERS.keys():
+ if trigger in ctx.content.lower():
+ await ctx.add_reaction(SPOOKY_TRIGGERS[trigger])
+
+
+def setup(bot):
+ bot.add_cog(SpookyReact(bot))
diff --git a/bot/cogs/template.py b/bot/cogs/template.py
index 89f12fe1..aa01432c 100644
--- a/bot/cogs/template.py
+++ b/bot/cogs/template.py
@@ -12,9 +12,12 @@ class Template:
@commands.command(name='repo', aliases=['repository', 'project'], brief='A link to the repository of this bot.')
async def repository(self, ctx):
+ """
+ A command to send the hacktoberbot github project
+ """
await ctx.send('https://github.com/discord-python/hacktoberbot')
- @commands.group(name='git', invoke_without_command=True)
+ @commands.group(name='git', invoke_without_command=True, brief="A link to resources for learning Git")
async def github(self, ctx):
"""
A command group with the name git. You can now create sub-commands such as git commit.
diff --git a/bot/resources/halloweenify.json b/bot/resources/halloweenify.json
new file mode 100644
index 00000000..88c46bfc
--- /dev/null
+++ b/bot/resources/halloweenify.json
@@ -0,0 +1,82 @@
+{
+ "characters": [
+ {
+ "Michael Myers": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f57x2f9353301x2fwzqoqvitx2fuqkpimt-ugmza-x78pwbw-c95x3fex3d438x26yx3d38x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Jason Voorhees": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f42x2f9050161x2fwzqoqvitx2friawv-dwwzpmma-nqtu-kpizikbmza-x78pwbw-c1x3fex3d438x26yx3d38x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Freddy Krueger": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fvwlm_quox2f30x2f9800154x2fwzqoqvitx2fnzmllg-szcmomz-nqtu-kpizikbmza-x78pwbw-9x3fex3d438x26yx3d38x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Pennywise the Dancing Clown": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f436x2f91927608x2fwzqoqvitx2fx78mvvgeqam-bpm-livkqvo-ktwev-nqtu-kpizikbmza-x78pwbw-c0x3fex3d438x26yx3d38x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Charles Lee Ray": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f14x2f581261x2fwzqoqvitx2fkpiztma-tmm-zig-c90x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Leatherface": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f50x2f9204163x2fwzqoqvitx2ftmibpmznikm-c08x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Jack Torrance": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fvwlm_quox2f41x2f9032825x2fwzqoqvitx2friks-bwzzivkm-nqtu-kpizikbmza-x78pwbw-9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Ghostface": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f32x2f9845748x2fwzqoqvitx2fopwabnikm-nqtu-kpizikbmza-x78pwbw-c9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Regan MacNeil": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f73x2f9665843x2fwzqoqvitx2fzmoiv-uikvmqt-zmkwzlqvo-izbqaba-ivl-ozwcx78a-x78pwbw-c1x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Hannibal Lecter": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f35x2f9903368x2fwzqoqvitx2fpivvqjit-tmkbmz-nqtu-kpizikbmza-x78pwbw-c99x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Samara Morgan": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f77x2f9744082x2fwzqoqvitx2faiuizi-uwzoiv-nqtu-kpizikbmza-x78pwbw-c9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Pinhead": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f79x2f9683232x2fwzqoqvitx2fx78qvpmil-nqtu-kpizikbmza-x78pwbw-c4x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Bruce": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f2047x2f63159728x2fwzqoqvitx2friea-c3x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Xenomorph": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f909x2f0297919x2fwzqoqvitx2ffmvwuwzx78p-x78pwbw-c91x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "The Thing": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f1947x2f41148806x2fwzqoqvitx2fbpm-bpqvo-x78pwbw-c6x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Sadako Yamamura": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f76x2f9735755x2fwzqoqvitx2failisw-giuiuczi-c4x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Jigsaw Killer": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fvwlm_quox2f43x2f9065767x2fwzqoqvitx2frqoaie-sqttmz-nqtu-kpizikbmza-x78pwbw-9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Norman Bates": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f63x2f9467317x2fwzqoqvitx2fvwzuiv-jibma-nqtu-kpizikbmza-x78pwbw-c9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Candyman": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f255x2f7305168x2fwzqoqvitx2fkivlguiv-c5x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "The Creeper": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fvwlm_quox2f998x2f0963452x2fwzqoqvitx2fbpm-kzmmx78mz-nqtu-kpizikbmza-x78pwbw-9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Damien Thorn": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f29x2f685981x2fwzqoqvitx2fliuqmv-bpwzv-x78pwbw-c1x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Joker": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f903x2f0276049x2fwzqoqvitx2frwsmz-c71x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Cujo": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f1581x2f52837948x2fwzqoqvitx2fkcrw-nqtu-kpizikbmza-x78pwbw-c9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Gage Creed": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fcamz_vwlm_quox2f1968x2f41364699x2fwzqoqvitx2foiom-kzmml-nqtu-kpizikbmza-x78pwbw-c9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Chatterer": "https://c-5uwzmx78pmca09x24quoqfx2ezivsmzx2ekwu.g00.ranker.com/g00/3_c-5eee.zivsmz.kwu_/c-5UWZMXPMCA09x24pbbx78ax3ax2fx2fquoqf.zivsmz.kwux2fvwlm_quox2f14x2f586061x2fwzqoqvitx2fkpibbmzmz-nqtu-kpizikbmza-x78pwbw-9x3fex3d438x26yx3d48x26nux3drx78ox26nqbx3dkzwx78x26kzwx78x3dnikmax22x26q98k.uizsx3dquiom_$/$/$/$/$/$"
+ },
+ {
+ "Pale Man": "https://i2.wp.com/macguff.in/wp-content/uploads/2016/10/Pans-Labyrinth-Movie-Header-Image.jpg?fit=630%2C400&ssl=1"
+ }
+ ]
+} \ No newline at end of file
diff --git a/docker/Dockerfile b/docker/Dockerfile
new file mode 100644
index 00000000..9c4406bf
--- /dev/null
+++ b/docker/Dockerfile
@@ -0,0 +1,15 @@
+FROM python:3.6-alpine3.7
+RUN apk add --update tini git
+
+RUN mkdir /bot
+COPY . /bot
+WORKDIR /bot
+
+ENV LIBRARY_PATH=/lib:/usr/lib
+
+RUN pip install pipenv
+RUN pipenv install --deploy --system
+
+ENTRYPOINT ["/sbin/tini", "--"]
+CMD ["python", "-m", "bot"]
+
diff --git a/docker/build.sh b/docker/build.sh
new file mode 100755
index 00000000..153fbccc
--- /dev/null
+++ b/docker/build.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+
+# Build and deploy on master branch
+if [[ $TRAVIS_BRANCH == 'master' && $TRAVIS_PULL_REQUEST == 'false' ]]; then
+ echo "Connecting to docker hub"
+ echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
+
+ echo "Building image"
+ docker build -t pythondiscord/hacktober-bot:latest -f docker/Dockerfile .
+
+ echo "Pushing image"
+ docker push pythondiscord/hacktober-bot:latest
+
+ echo "Deploying on server"
+ pepper ${SALTAPI_TARGET} state.apply docker/hacktoberbot --out=no_out --non-interactive &> /dev/null
+else
+ echo "Skipping deploy"
+fi
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
new file mode 100644
index 00000000..0a802ddc
--- /dev/null
+++ b/docker/docker-compose.yml
@@ -0,0 +1,11 @@
+version: "3"
+services:
+ dumbo:
+ image: pythondiscord/hacktober-bot:latest
+ container_name: hacktoberbot
+
+ restart: always
+
+ environment:
+ - HACKTOBERBOT_TOKEN
+
diff --git a/tox.ini b/tox.ini
index 780c31d7..bff048cb 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,6 @@
[flake8]
max-line-length=120
-application_import_names=proj
+application_import_names=bot
ignore=P102,B311,W503,E226,S311
exclude=__pycache__, venv, .venv, tests
import-order-style=pycharm