diff --git a/.gitignore b/.gitignore index b7984a5..4a9249c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ dblpy.egg-info/ dbl/__pycache__/ +dbl/webhookserverhandler.py build/ dist/ /docs/_build /docs/_static/ /docs/_templates -.vscode \ No newline at end of file +.vscode diff --git a/README.rst b/README.rst index 6cd929b..2def48c 100644 --- a/README.rst +++ b/README.rst @@ -5,9 +5,9 @@ DBL Python Library :alt: View on PyPi .. image:: https://img.shields.io/pypi/pyversions/dblpy.svg :target: https://pypi.python.org/pypi/dblpy - :alt: v0.1.4 -.. image:: https://readthedocs.org/projects/dblpy/badge/?version=v0.1.4 - :target: http://dblpy.readthedocs.io/en/latest/?badge=v0.1.4 + :alt: v0.1.6 +.. image:: https://readthedocs.org/projects/dblpy/badge/?version=v0.1.6 + :target: http://dblpy.readthedocs.io/en/latest/?badge=v0.1.6 :alt: Documentation Status A simple API wrapper for `discordbots.org`_ written in Python @@ -66,25 +66,21 @@ Example def __init__(self, bot): self.bot = bot self.token = 'dbl_token' # set this to your DBL token - self.dblpy = dbl.Client(self.bot, self.token) - self.bot.loop.create_task(self.update_stats()) + self.dblpy = dbl.Client(self.bot, self.token, loop=bot.loop) + self.updating = bot.loop.create_task(self.update_stats()) async def update_stats(self): """This function runs every 30 minutes to automatically update your server count""" - - while True: - logger.info('attempting to post server count') + await self.bot.is_ready() + while not bot.is_closed: + logger.info('Attempting to post server count') try: await self.dblpy.post_server_count() - logger.info('posted server count ({})'.format(len(self.bot.guilds))) + logger.info('Posted server count ({})'.format(len(self.bot.guilds))) except Exception as e: logger.exception('Failed to post server count\n{}: {}'.format(type(e).__name__, e)) await asyncio.sleep(1800) - def __unload(self): - self.bot.loop.create_task(self.session.close()) - - def setup(bot): global logger logger = logging.getLogger('bot') diff --git a/dbl/__init__.py b/dbl/__init__.py index 3187593..fa03d93 100644 --- a/dbl/__init__.py +++ b/dbl/__init__.py @@ -12,7 +12,7 @@ __author__ = 'Francis Taylor' __license__ = 'MIT' __copyright__ = 'Copyright 2018 Francis Taylor' -__version__ = '0.1.4' +__version__ = '0.1.6' from collections import namedtuple @@ -22,4 +22,4 @@ VersionInfo = namedtuple('VersionInfo', 'major minor micro releaselevel serial') -version_info = VersionInfo(major=0, minor=1, micro=4, releaselevel='final', serial=0) +version_info = VersionInfo(major=0, minor=1, micro=6, releaselevel='final', serial=0) diff --git a/dbl/client.py b/dbl/client.py index 0e364f6..4b383a0 100644 --- a/dbl/client.py +++ b/dbl/client.py @@ -25,10 +25,15 @@ """ import asyncio +#import string +#import random +import logging from . import __version__ as library_version -from .errors import * from .http import HTTPClient +#from .errors import InvalidArgument + +log = logging.getLogger(__name__) class Client: @@ -67,15 +72,16 @@ async def __ainit__(self): self.bot_id = self.bot.user.id def guild_count(self): + """Gets the guild count from the bot object""" try: return len(self.bot.guilds) except AttributeError: return len(self.bot.servers) async def post_server_count( - self, - shard_count: int = None, - shard_no: int = None + self, + shard_count: int = None, + shard_no: int = None ): """This function is a coroutine. @@ -246,15 +252,15 @@ async def get_user_info(self, user_id: int): return await self.http.get_user_info(user_id) async def generate_widget_large( - self, - bot_id: int = None, - top: str = '2C2F33', - mid: str = '23272A', - user: str = 'FFFFFF', - cert: str = 'FFFFFF', - data: str = 'FFFFFF', - label: str = '99AAB5', - highlight: str = '2C2F33' + self, + bot_id: int = None, + top: str = '2C2F33', + mid: str = '23272A', + user: str = 'FFFFFF', + cert: str = 'FFFFFF', + data: str = 'FFFFFF', + label: str = '99AAB5', + highlight: str = '2C2F33' ): """This function is a coroutine. @@ -313,13 +319,13 @@ async def get_widget_large(self, bot_id: int = None): return url async def generate_widget_small( - self, - bot_id: int = None, - avabg: str = '2C2F33', - lcol: str = '23272A', - rcol: str = '2C2F33', - ltxt: str = 'FFFFFF', - rtxt: str = 'FFFFFF' + self, + bot_id: int = None, + avabg: str = '2C2F33', + lcol: str = '23272A', + rcol: str = '2C2F33', + ltxt: str = 'FFFFFF', + rtxt: str = 'FFFFFF' ): """This function is a coroutine. @@ -374,6 +380,45 @@ async def get_widget_small(self, bot_id: int = None): url = 'https://discordbots.org/api/widget/lib/{0}.png'.format(bot_id) return url + # async def start_vote_post(self, url: str = None, auth: str = None): + # """This function is a coroutine. + # + # Sets up webhooks for posting a message to a Discord channel whenever someone votes on your bot. + # + # .. note:: + # + # This function will automatically start a webserver to listen to incoming requests. You should point the webhooks to the IP or domain of your host. + # + # Parameters + # ========== + # + # auth: str[Optional] + # The authorization token (password) that will be used to verify requests coming back from DBL. Generate a random token with ``auth_generator()`` + # + # Returns + # ======= + # + # bot_id: int + # ID of the bot that received a vote. + # user_id: int s.wfile.write('Hello!') + # ID of the user who voted. + # type: str + # The type of vote. 'upvote' or 'none' (unvote) + # query?: str + # Query string params found on the `/bot/:ID/vote` page. + # """ + # if auth is None: + # log.warn( + # 'Webhook validation token is Null. Please set one, or generate one using `auth_generator()`.') + # + # await self.http.initialize_webhooks(url, auth) + + # async def auth_generator(self, size=32, chars=string.ascii_uppercase + string.ascii_lowercase + string.digits): + # """This function is a coroutine + # + # Generates a random auth token for webhook validation.""" + # return ''.join(random.SystemRandom().choice(chars) for _ in range(size)) + async def close(self): """This function is a coroutine. Closes all connections.""" diff --git a/dbl/errors.py b/dbl/errors.py index 0b76311..f37594d 100644 --- a/dbl/errors.py +++ b/dbl/errors.py @@ -79,7 +79,7 @@ class Unauthorized(HTTPException): pass -class Unauthorized_Detected(DBLException): +class UnauthorizedDetected(DBLException): """Exception that's thrown when no API Token is provided Subclass of :exc:`DBLException` diff --git a/dbl/http.py b/dbl/http.py index e041e0e..c454999 100644 --- a/dbl/http.py +++ b/dbl/http.py @@ -25,46 +25,32 @@ """ import asyncio +import os import json import logging import sys -import weakref -from ratelimiter import RateLimiter -from datetime import datetime, timedelta +from datetime import datetime # , timedelta from urllib.parse import urlencode +from ratelimiter import RateLimiter import aiohttp from . import __version__ from .errors import * +#from .webhookserverhandler import WebHook log = logging.getLogger(__name__) @asyncio.coroutine def json_or_text(response): + """Turns response into a properly formatted json or text object""" text = response.text(encoding='utf-8') if response.headers['content-type'] == 'application/json': return json.loads(text) return text -# class MaybeUnlock: -# def __init__(self, lock): -# self.lock = lock -# self._unlock = True -# -# def __enter__(self): -# return self -# -# def defer(self): -# self._unlock = False -# -# def __exit__(self, type, value, traceback): -# if self._unlock: -# self.lock.release() - - class HTTPClient: """Represents an HTTP client sending HTTP requests to the DBL API. @@ -84,53 +70,24 @@ def __init__(self, token, **kwargs): self.token = token self.loop = asyncio.get_event_loop() if kwargs.get('loop') is None else kwargs.get('loop') self.session = kwargs.get('session') or aiohttp.ClientSession(loop=kwargs.get('loop')) - #self._locks = weakref.WeakValueDictionary() - # self._rl = weakref.WeakValueDictionary() self._global_over = asyncio.Event(loop=self.loop) self._global_over.set() - self.rate_limiter = RateLimiter(max_calls=60, period=60, - callback=await self.limited) # handles ratelimits user_agent = 'DBL-Python-Library (https://github.com/DiscordBotList/DBL-Python-Library {0}) Python/{1[0]}.{1[1]} aiohttp/{2}' self.user_agent = user_agent.format(__version__, sys.version_info, aiohttp.__version__) +# TODO: better implementation of ratelimits +# NOTE: current implementation doesn't (a) maintain state over restart and (b) wait an hour when a 429 is hit + async def request(self, method, url, **kwargs): - async with self.rate_limiter: # this works but doesn't 'save' over restart. just pray and hope? - #lock = self._locks.get(url) - # if lock is None: - # lock = asyncio.Lock(loop=self.loop) - # if url is not None: - # self._locks[url] = lock - - # # key error -> quota - # log.debug(self._rl) - # quota = self._rl.get() - # log.debug(quota) - # if self._rl['quota'] is None: - # log.debug('remaining quota is Null.') - # remaining = 60 - # self._rl['quota'] = remaining - # log.debug('setting ratelimit quota to default (%s)', remaining) - # remaining = self._rl['quota'] - # log.debug(remaining) # TEST - # reset_at = self._rl['reset'] - # log.debug(reset_at) # TEST - # if reset_at is None: - # log.debug('reset time is Null.') - # reset_at = datetime.now() + timedelta(minutes=1) - # self._rl['reset'] = reset_at - # log.debug('setting reset time: %s', reset_at) - # if reset_at.timestamp() < datetime.now().timestamp(): - # log.debug('passed ratelimit quota reset time, resetting.') - # remaining = 60 - # self._rl['quota'] = remaining - # log.debug('reset ratelimit quota') - # else: - # remaining = remaining - 1 - # self._rl['quota'] = remaining + """Handles requests to the API""" + rate_limiter = RateLimiter(max_calls=59, period=60, callback=limited) + # handles ratelimits. max_calls is set to 59 because current implementation will retry in 60s after 60 calls is reached. DBL has a 1h block so obviously this doesn't work well, as it will get a 429 when 60 is reached. + + async with rate_limiter: # this works but doesn't 'save' over restart. need a better implementation. if not self.token: - raise Unauthorized_Detected('Unauthorized (status code: 401): No TOKEN provided') + raise UnauthorizedDetected('Unauthorized (status code: 401): No TOKEN provided') headers = { 'User-Agent': self.user_agent, @@ -214,7 +171,7 @@ async def request(self, method, url, **kwargs): async def close(self): await self.session.close() - def recreate(self): + async def recreate(self): self.session = aiohttp.ClientSession(loop=self.session.loop) async def post_server_count(self, bot_id, guild_count, shard_count, shard_no): @@ -229,15 +186,15 @@ async def post_server_count(self, bot_id, guild_count, shard_count, shard_no): 'server_count': guild_count } - await self.request('POST', f'{self.BASE}/bots/{bot_id}/stats', json=payload) + await self.request('POST', '{}/bots/{}/stats'.format(self.BASE, bot_id), json=payload) async def get_server_count(self, bot_id): '''Gets the server count of the given Bot ID''' - return await self.request('GET', f'{self.BASE}/bots/{bot_id}/stats') + return await self.request('GET', '{}/bots/{}/stats'.format(self.BASE, bot_id)) async def get_bot_info(self, bot_id): '''Gets the information of the given Bot ID''' - resp = await self.request('GET', f'{self.BASE}/bots/{bot_id}') + resp = await self.request('GET', '{}/bots/{}'.format(self.BASE, bot_id)) resp['date'] = datetime.strptime(resp['date'], '%Y-%m-%dT%H:%M:%S.%fZ') for k in resp: if resp[k] == '': @@ -250,27 +207,39 @@ async def get_upvote_info(self, bot_id, onlyids, days): 'onlyids': onlyids, 'days': days } - return await self.request('GET', f'{self.BASE}/bots/{bot_id}/votes' + urlencode(params)) + return await self.request('GET', '{}/bots/{}/votes?'.format(self.BASE, bot_id) + urlencode(params)) async def get_bots(self, limit, offset): - return await self.request('GET', f'{self.BASE}/bots?limit={limit}&offset={offset}') + '''Gets an object of all the bots on DBL''' + return await self.request('GET', '{}/bots?limit={}&offset={}'.format(self.BASE, limit, offset)) - # async def get_bots(self, limit, offset, query): + # async def search_bots(self, limit, offset, query): # return await self.request('GET', f'{self.BASE}/bots?limit={limit}&offset={offset}&search={query}') async def get_user_info(self, user_id): - return await self.request('GET', f'{self.BASE}/users/{user_id}') + '''Gets an object of the user on DBL''' + return await self.request('GET', '{}/users/{}'.format(self.BASE, user_id)) + + # async def initialize_webhooks(self, auth: str = None): + # '''Initializes the webhook server''' + # # setup webhook server + # os.environ['HOOK_AUTH'] = str(auth) + # os.environ['WEBHOOKS'] = True + # await self.start_websocket_server() @property def bucket(self): # the bucket is just method + path w/ major parameters return '{0.method}:{0.url}'.format(self) - async def limited(until): - duration = int(round(until - time.time())) - mins = duration / 60 - fmt = 'We have exhausted a ratelimit quota. Retrying in %.2f seconds (%.3f minutes).' - log.warn(fmt, duration, mins) + +@asyncio.coroutine +def limited(until): + """Handles the message shown when we are ratelimited""" + duration = int(round(until - time.time())) + mins = duration / 60 + fmt = 'We have exhausted a ratelimit quota. Retrying in %.2f seconds (%.3f minutes).' + log.warn(fmt, duration, mins) def to_json(obj): diff --git a/docs/api.rst b/docs/api.rst index 1853fbd..eb35020 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -54,7 +54,6 @@ The following exceptions are thrown by the library. .. autoexception:: NotFound - .. autoexception:: InvalidArgument .. autoexception:: ConnectionClosed diff --git a/docs/whats_new.rst b/docs/whats_new.rst index dd3fc5b..5d50523 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -8,7 +8,17 @@ What's New This page keeps a detailed human friendly rendering of what's new and changed in specific versions. -.. _vp0p1p3: +.. _vp0p1p6: + +v0.1.6 +------ + +* Bug fixes & improvements + +v0.1.4 +------ + +* Initial ratelimit handling v0.1.3 ------