diff --git a/tests/test_autopost.py b/tests/test_autopost.py index a4f8ee7a..b83a0cc0 100644 --- a/tests/test_autopost.py +++ b/tests/test_autopost.py @@ -5,9 +5,12 @@ from aiohttp import ClientSession from pytest_mock import MockerFixture -from topgg import DBLClient, StatsWrapper +from topgg import DBLClient from topgg.autopost import AutoPoster -from topgg.errors import ServerError, TopGGException, Unauthorized +from topgg.errors import HTTPException, TopGGException + + +MOCK_TOKEN = ".eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=." @pytest.fixture @@ -17,7 +20,7 @@ def session() -> ClientSession: @pytest.fixture def autopost(session: ClientSession) -> AutoPoster: - return AutoPoster(DBLClient("", session=session)) + return AutoPoster(DBLClient(MOCK_TOKEN, session=session)) @pytest.mark.asyncio @@ -29,15 +32,15 @@ async def test_AutoPoster_breaks_autopost_loop_on_401( response.status = 401 mocker.patch( - "topgg.DBLClient.post_guild_count", side_effect=Unauthorized(response, {}) + "topgg.DBLClient.post_guild_count", side_effect=HTTPException(response, {}) ) callback = mock.Mock() - autopost = DBLClient("", session=session).autopost().stats(callback) + autopost = DBLClient(MOCK_TOKEN, session=session).autopost().stats(callback) assert isinstance(autopost, AutoPoster) assert not isinstance(autopost.stats()(callback), AutoPoster) - with pytest.raises(Unauthorized): + with pytest.raises(HTTPException): await autopost.start() callback.assert_called_once() @@ -73,7 +76,7 @@ async def test_AutoPoster_error_callback( response = mock.Mock("reason, status") response.reason = "Internal Server Error" response.status = 500 - side_effect = ServerError(response, {}) + side_effect = HTTPException(response, {}) mocker.patch("topgg.DBLClient.post_guild_count", side_effect=side_effect) task = autopost.on_error(error_callback).stats(mock.Mock()).start() diff --git a/tests/test_client.py b/tests/test_client.py index fb634ead..f0a9c456 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,137 +1,54 @@ -import typing as t - import mock import pytest -from aiohttp import ClientSession import topgg -from topgg import errors - - -@pytest.fixture -def session() -> ClientSession: - return mock.Mock(ClientSession) - - -@pytest.fixture -def client() -> topgg.DBLClient: - client = topgg.DBLClient(token="TOKEN", default_bot_id=1234) - client.http = mock.Mock(topgg.http.HTTPClient) - return client - - -@pytest.mark.asyncio -async def test_HTTPClient_with_external_session(session: ClientSession): - http = topgg.http.HTTPClient("TOKEN", session=session) - assert not http._own_session - await http.close() - session.close.assert_not_called() - - -@pytest.mark.asyncio -async def test_HTTPClient_with_no_external_session(session: ClientSession): - http = topgg.http.HTTPClient("TOKEN") - http.session = session - assert http._own_session - await http.close() - session.close.assert_called_once() -@pytest.mark.asyncio -async def test_DBLClient_get_bot_votes_with_no_default_bot_id(): - client = topgg.DBLClient("TOKEN") - with pytest.raises( - errors.ClientException, - match="you must set default_bot_id when constructing the client.", - ): - await client.get_bot_votes() +MOCK_TOKEN = ".eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=." @pytest.mark.asyncio async def test_DBLClient_post_guild_count_with_no_args(): - client = topgg.DBLClient("TOKEN", default_bot_id=1234) + client = topgg.DBLClient(MOCK_TOKEN) with pytest.raises(TypeError, match="stats or guild_count must be provided."): await client.post_guild_count() -@pytest.mark.parametrize( - "method, kwargs", - [ - (topgg.DBLClient.get_guild_count, {}), - (topgg.DBLClient.get_bot_info, {}), - ( - topgg.DBLClient.generate_widget, - { - "options": topgg.types.WidgetOptions(), - }, - ), - ], -) @pytest.mark.asyncio -async def test_DBLClient_get_guild_count_with_no_id( - method: t.Callable, kwargs: t.Dict[str, t.Any] -): - client = topgg.DBLClient("TOKEN") - with pytest.raises( - errors.ClientException, match="bot_id or default_bot_id is unset." - ): - await method(client, **kwargs) - - -@pytest.mark.asyncio -async def test_closed_DBLClient_raises_exception(): - client = topgg.DBLClient("TOKEN") - assert not client.is_closed - await client.close() - assert client.is_closed - with pytest.raises(errors.ClientException, match="client has been closed."): - await client.get_weekend_status() - - -@pytest.mark.asyncio -async def test_DBLClient_get_weekend_status(client: topgg.DBLClient): - client.http.get_weekend_status = mock.AsyncMock() +async def test_DBLClient_get_weekend_status(monkeypatch): + client = topgg.DBLClient(MOCK_TOKEN) + monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock()) await client.get_weekend_status() - client.http.get_weekend_status.assert_called_once() + client._DBLClient__request.assert_called_once() @pytest.mark.asyncio -async def test_DBLClient_post_guild_count(client: topgg.DBLClient): - client.http.post_guild_count = mock.AsyncMock() +async def test_DBLClient_post_guild_count(monkeypatch): + client = topgg.DBLClient(MOCK_TOKEN) + monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock()) await client.post_guild_count(guild_count=123) - client.http.post_guild_count.assert_called_once() + client._DBLClient__request.assert_called_once() @pytest.mark.asyncio -async def test_DBLClient_get_guild_count(client: topgg.DBLClient): - client.http.get_guild_count = mock.AsyncMock(return_value={}) +async def test_DBLClient_get_guild_count(monkeypatch): + client = topgg.DBLClient(MOCK_TOKEN) + monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock(return_value={})) await client.get_guild_count() - client.http.get_guild_count.assert_called_once() + client._DBLClient__request.assert_called_once() @pytest.mark.asyncio -async def test_DBLClient_get_bot_votes(client: topgg.DBLClient): - client.http.get_bot_votes = mock.AsyncMock(return_value=[]) +async def test_DBLClient_get_bot_votes(monkeypatch): + client = topgg.DBLClient(MOCK_TOKEN) + monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock(return_value=[])) await client.get_bot_votes() - client.http.get_bot_votes.assert_called_once() - - -@pytest.mark.asyncio -async def test_DBLClient_get_bots(client: topgg.DBLClient): - client.http.get_bots = mock.AsyncMock(return_value={"results": []}) - await client.get_bots() - client.http.get_bots.assert_called_once() - - -@pytest.mark.asyncio -async def test_DBLClient_get_user_info(client: topgg.DBLClient): - client.http.get_user_info = mock.AsyncMock(return_value={}) - await client.get_user_info(1234) - client.http.get_user_info.assert_called_once() + client._DBLClient__request.assert_called_once() @pytest.mark.asyncio -async def test_DBLClient_get_user_vote(client: topgg.DBLClient): - client.http.get_user_vote = mock.AsyncMock(return_value={"voted": 1}) +async def test_DBLClient_get_user_vote(monkeypatch): + client = topgg.DBLClient(MOCK_TOKEN) + monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock(return_value={"voted": 1})) await client.get_user_vote(1234) - client.http.get_user_vote.assert_called_once() + client._DBLClient__request.assert_called_once() diff --git a/tests/test_ratelimiter.py b/tests/test_ratelimiter.py index f1fbed6b..998a7357 100644 --- a/tests/test_ratelimiter.py +++ b/tests/test_ratelimiter.py @@ -1,26 +1,26 @@ import pytest -from topgg.ratelimiter import AsyncRateLimiter +from topgg.ratelimiter import Ratelimiter n = period = 10 @pytest.fixture -def limiter() -> AsyncRateLimiter: - return AsyncRateLimiter(max_calls=n, period=period) +def limiter() -> Ratelimiter: + return Ratelimiter(max_calls=n, period=period) @pytest.mark.asyncio -async def test_AsyncRateLimiter_calls(limiter: AsyncRateLimiter) -> None: +async def test_AsyncRateLimiter_calls(limiter: Ratelimiter) -> None: for _ in range(n): async with limiter: pass - assert len(limiter.calls) == limiter.max_calls == n + assert len(limiter._Ratelimiter__calls) == limiter._Ratelimiter__max_calls == n @pytest.mark.asyncio -async def test_AsyncRateLimiter_timespan_property(limiter: AsyncRateLimiter) -> None: +async def test_AsyncRateLimiter_timespan_property(limiter: Ratelimiter) -> None: for _ in range(n): async with limiter: pass diff --git a/topgg/__init__.py b/topgg/__init__.py index 1a9025eb..58c1bce7 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -8,17 +8,18 @@ :license: MIT, see LICENSE for more details. """ +from .version import VERSION + __title__ = "topggpy" __author__ = "Assanali Mukhanov" __maintainer__ = "Norizon" __license__ = "MIT" -__version__ = "2.0.0a1" +__version__ = VERSION from .autopost import * from .client import * from .data import * from .errors import * -from .http import * # can't be added to __all__ since they'd clash with automodule from .types import * diff --git a/topgg/autopost.py b/topgg/autopost.py index 3bfe4afa..c1953f89 100644 --- a/topgg/autopost.py +++ b/topgg/autopost.py @@ -269,7 +269,7 @@ async def _internal_loop(self) -> None: await self.client.post_guild_count(stats) except Exception as err: await self.client._invoke_callback(self._error, err) - if isinstance(err, errors.Unauthorized): + if isinstance(err, errors.HTTPException) and err.code == 401: raise err from None else: on_success = getattr(self, "_success", None) diff --git a/topgg/client.py b/topgg/client.py index 0f1a72db..42c714e7 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -1,37 +1,47 @@ -# -*- coding: utf-8 -*- - -# The MIT License (MIT) - -# Copyright (c) 2021 Assanali Mukhanov - -# Permission is hereby granted, free of charge, to any person obtaining a -# copy of this software and associated documentation files (the "Software"), -# to deal in the Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, distribute, sublicense, -# and/or sell copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. +""" +The MIT License (MIT) + +Copyright (c) 2021 Assanali Mukhanov & Top.gg +Copyright (c) 2024-2025 null8626 & Top.gg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" __all__ = ["DBLClient"] +from collections import namedtuple +from base64 import b64decode +from time import time import typing as t - +import binascii import aiohttp +import asyncio +import json -from . import errors, types from .autopost import AutoPoster +from . import errors, types, VERSION from .data import DataContainerMixin -from .http import HTTPClient +from .ratelimiter import Ratelimiter, Ratelimiters + + +BASE_URL = 'https://top.gg/api' +MAXIMUM_DELAY_THRESHOLD = 5.0 class DBLClient(DataContainerMixin): @@ -42,53 +52,121 @@ class DBLClient(DataContainerMixin): .. _aiohttp session: https://aiohttp.readthedocs.io/en/stable/client_reference.html#client-session Args: - token (:obj:`str`): Your bot's Top.gg API Token. + token (:obj:`str`): Your Top.gg API Token. Keyword Args: - default_bot_id (:obj:`typing.Optional` [ :obj:`int` ]) - The default bot_id. You can override this by passing it when calling a method. session (:class:`aiohttp.ClientSession`) An `aiohttp session`_ to use for requests to the API. - **kwargs: - Arbitrary kwargs to be passed to :class:`aiohttp.ClientSession` if session was not provided. """ - __slots__ = ("http", "default_bot_id", "_token", "_is_closed", "_autopost") - http: HTTPClient + __slots__ = ("id", "_token", "_autopost", "__session", "__own_session", "__ratelimiter", "__ratelimiters", "__current_ratelimit") def __init__( self, token: str, *, - default_bot_id: t.Optional[int] = None, session: t.Optional[aiohttp.ClientSession] = None, - **kwargs: t.Any, ) -> None: super().__init__() + + self.__own_session = session is None + self.__session = session or aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=MAXIMUM_DELAY_THRESHOLD * 1000.0) + ) self._token = token - self.default_bot_id = default_bot_id - self._is_closed = False - if session is not None: - self.http = HTTPClient(token, session=session) self._autopost: t.Optional[AutoPoster] = None - @property - def is_closed(self) -> bool: - return self._is_closed + try: + encoded_json = token.split('.')[1] + encoded_json += '=' * (4 - (len(encoded_json) % 4)) + encoded_json = json.loads(b64decode(encoded_json)) + + self.id = int(encoded_json['id']) + except (IndexError, ValueError, binascii.Error, json.decoder.JSONDecodeError): + raise ValueError('Got a malformed API token.') from None - async def _ensure_session(self) -> None: + endpoint_ratelimits = namedtuple('EndpointRatelimits', 'global_ bot') + + self.__ratelimiter = endpoint_ratelimits( + global_=Ratelimiter(99, 1), bot=Ratelimiter(59, 60) + ) + self.__ratelimiters = Ratelimiters(self.__ratelimiter) + self.__current_ratelimit = None + + async def __request( + self, + method: str, + path: str, + params: t.Optional[dict] = None, + body: t.Optional[dict] = None, + ) -> dict: if self.is_closed: - raise errors.ClientStateException("client has been closed.") + raise errors.ClientStateException('Client session is already closed.') + + if self.__current_ratelimit is not None: + current_time = time() + + if current_time < self.__current_ratelimit: + raise errors.Ratelimited(self.__current_ratelimit - current_time) + else: + self.__current_ratelimit = None + + ratelimiter = ( + self.__ratelimiters if path.startswith('/bots') else self.__ratelimiter.global_ + ) + + kwargs = {} + + if body: + kwargs['json'] = body + + if params: + kwargs['params'] = params + + response = None + retry_after = None + output = None + + async with ratelimiter: + try: + response = await self.__session.request( + method, + BASE_URL + path, + headers={ + 'Authorization': f'Bearer {self.__token}', + 'Content-Type': 'application/json', + 'User-Agent': f'topggpy (https://github.com/top-gg-community/python-sdk {VERSION}) Python/', + }, + **kwargs, + ) + + retry_after = float(response.headers.get('Retry-After', 0)) - if not hasattr(self, "http"): - self.http = HTTPClient(self._token, session=None) + if 'json' in response.headers['Content-Type']: + try: + output = await response.json() + except json.decoder.JSONDecodeError: + pass - def _validate_and_get_bot_id(self, bot_id: t.Optional[int]) -> int: - bot_id = bot_id or self.default_bot_id - if bot_id is None: - raise errors.ClientException("bot_id or default_bot_id is unset.") + response.raise_for_status() - return bot_id + return output + except aiohttp.ClientResponseError: + if response.status == 429: + if retry_after > MAXIMUM_DELAY_THRESHOLD: + self.__current_ratelimit = time() + retry_after + + raise errors.Ratelimited(retry_after) from None + + await asyncio.sleep(retry_after) + + return await self.__request(method, path) + + raise errors.HTTPException(response, output) from None + + @property + def is_closed(self) -> bool: + return self.__session.closed async def get_weekend_status(self) -> bool: """Gets weekend status from Top.gg. @@ -100,9 +178,10 @@ async def get_weekend_status(self) -> bool: :obj:`~.errors.ClientStateException` If the client has been closed. """ - await self._ensure_session() - data = await self.http.get_weekend_status() - return data["is_weekend"] + + response = await self.__request('GET', '/weekend') + + return response['is_weekend'] @t.overload async def post_guild_count(self, stats: types.StatsWrapper) -> None: @@ -123,8 +202,6 @@ async def post_guild_count( stats: t.Any = None, *, guild_count: t.Any = None, - shard_count: t.Any = None, - shard_id: t.Any = None, ) -> None: """Posts your bot's guild count and shards info to Top.gg. @@ -141,10 +218,6 @@ async def post_guild_count( guild_count (:obj:`typing.Optional` [:obj:`typing.Union` [ :obj:`int`, :obj:`list` [ :obj:`int` ]]]) Number of guilds the bot is in. Applies the number to a shard instead if shards are specified. If not specified, length of provided client's property `.guilds` will be posted. - shard_count (:obj:`.typing.Optional` [ :obj:`int` ]) - The total number of shards. - shard_id (:obj:`.typing.Optional` [ :obj:`int` ]) - The index of the current shard. Top.gg uses `0 based indexing`_ for shards. Raises: TypeError @@ -154,16 +227,12 @@ async def post_guild_count( """ if stats: guild_count = stats.guild_count - shard_count = stats.shard_count - shard_id = stats.shard_id elif guild_count is None: raise TypeError("stats or guild_count must be provided.") - await self._ensure_session() - await self.http.post_guild_count(guild_count, shard_count, shard_id) - async def get_guild_count( - self, bot_id: t.Optional[int] = None - ) -> types.BotStatsData: + await self.__request('POST', '/bots/stats', body={'server_count': guild_count}) + + async def get_guild_count(self) -> types.BotStatsData: """Gets a bot's guild count and shard info from Top.gg. Args: @@ -175,14 +244,12 @@ async def get_guild_count( The guild count and shards of a bot on Top.gg. Raises: - :obj:`~.errors.ClientException` - If neither bot_id or default_bot_id was set. :obj:`~.errors.ClientStateException` If the client has been closed. """ - bot_id = self._validate_and_get_bot_id(bot_id) - await self._ensure_session() - response = await self.http.get_guild_count(bot_id) + + response = await self.__request('GET', '/bots/stats') + return types.BotStatsData(**response) async def get_bot_votes(self) -> t.List[types.BriefUserData]: @@ -196,17 +263,12 @@ async def get_bot_votes(self) -> t.List[types.BriefUserData]: Users who voted for your bot. Raises: - :obj:`~.errors.ClientException` - If default_bot_id isn't provided when constructing the client. :obj:`~.errors.ClientStateException` If the client has been closed. """ - if not self.default_bot_id: - raise errors.ClientException( - "you must set default_bot_id when constructing the client." - ) - await self._ensure_session() - response = await self.http.get_bot_votes(self.default_bot_id) + + response = await self.__request('GET', f'/bots/{self.id}/votes') + return [types.BriefUserData(**user) for user in response] async def get_bot_info(self, bot_id: t.Optional[int] = None) -> types.BotData: @@ -224,79 +286,13 @@ async def get_bot_info(self, bot_id: t.Optional[int] = None) -> types.BotData: `here `_. Raises: - :obj:`~.errors.ClientException` - If neither bot_id or default_bot_id was set. :obj:`~.errors.ClientStateException` If the client has been closed. """ - bot_id = self._validate_and_get_bot_id(bot_id) - await self._ensure_session() - response = await self.http.get_bot_info(bot_id) + bot_id = bot_id or self.id + response = await self.__request('GET', f'/bots/{bot_id}') return types.BotData(**response) - async def get_bots( - self, - limit: int = 50, - offset: int = 0, - sort: t.Optional[str] = None, - search: t.Optional[t.Dict[str, t.Any]] = None, - fields: t.Optional[t.List[str]] = None, - ) -> types.DataDict[str, t.Any]: - """This function is a coroutine. - - Gets information about listed bots on Top.gg. - - Args: - limit (int) - The number of results to look up. Defaults to 50. Max 500 allowed. - offset (int) - The amount of bots to skip. Defaults to 0. - sort (str) - The field to sort by. Prefix with ``-`` to reverse the order. - search (:obj:`dict` [ :obj:`str`, :obj:`typing.Any` ]) - The search data. - fields (:obj:`list` [ :obj:`str` ]) - Fields to output. - - Returns: - :obj:`~.types.DataDict`: - Info on bots that match the search query on Top.gg. - - Raises: - :obj:`~.errors.ClientStateException` - If the client has been closed. - """ - sort = sort or "" - search = search or {} - fields = fields or [] - await self._ensure_session() - response = await self.http.get_bots(limit, offset, sort, search, fields) - response["results"] = [ - types.BotData(**bot_data) for bot_data in response["results"] - ] - return types.DataDict(**response) - - async def get_user_info(self, user_id: int) -> types.UserData: - """This function is a coroutine. - - Gets information about a user on Top.gg. - - Args: - user_id (int) - ID of the user to look up. - - Returns: - :obj:`~.types.UserData`: - Information about a Top.gg user. - - Raises: - :obj:`~.errors.ClientStateException` - If the client has been closed. - """ - await self._ensure_session() - response = await self.http.get_user_info(user_id) - return types.UserData(**response) - async def get_user_vote(self, user_id: int) -> bool: """Gets information about a user's vote for your bot on Top.gg. @@ -308,68 +304,14 @@ async def get_user_vote(self, user_id: int) -> bool: :obj:`bool`: Info about the user's vote. Raises: - :obj:`~.errors.ClientException` - If default_bot_id isn't provided when constructing the client. :obj:`~.errors.ClientStateException` If the client has been closed. """ - if not self.default_bot_id: - raise errors.ClientException( - "you must set default_bot_id when constructing the client." - ) - await self._ensure_session() - data = await self.http.get_user_vote(self.default_bot_id, user_id) + data = await self.__request('GET', '/bots/check', params={'userId': user_id}) + return bool(data["voted"]) - def generate_widget(self, *, options: types.WidgetOptions) -> str: - """ - Generates a Top.gg widget from the provided :obj:`~.types.WidgetOptions` object. - - Keyword Arguments: - options (:obj:`~.types.WidgetOptions`) - A :obj:`~.types.WidgetOptions` object containing widget parameters. - - Returns: - str: Generated widget URL. - - Raises: - :obj:`~.errors.ClientException` - If bot_id or default_bot_id is unset. - TypeError: - If options passed is not of type WidgetOptions. - """ - if not isinstance(options, types.WidgetOptions): - raise TypeError( - "options argument passed to generate_widget must be of type WidgetOptions" - ) - - bot_id = options.id or self.default_bot_id - if bot_id is None: - raise errors.ClientException("bot_id or default_bot_id is unset.") - - widget_query = f"noavatar={str(options.noavatar).lower()}" - for key, value in options.colors.items(): - widget_query += f"&{key.lower()}{'' if key.lower().endswith('color') else 'color'}={value:x}" - widget_format = options.format - widget_type = f"/{options.type}" if options.type else "" - - url = f"""https://top.gg/api/widget{widget_type}/{bot_id}.{widget_format}?{widget_query}""" - return url - - async def close(self) -> None: - """Closes all connections.""" - if self.is_closed: - return - - if hasattr(self, "http"): - await self.http.close() - - if self._autopost: - self._autopost.cancel() - - self._is_closed = True - def autopost(self) -> AutoPoster: """Returns a helper instance for auto-posting. @@ -385,3 +327,18 @@ def autopost(self) -> AutoPoster: self._autopost = AutoPoster(self) return self._autopost + + async def close(self) -> None: + """Closes all connections.""" + + if self._autopost: + self._autopost.cancel() + + if self.__own_session and not self.__session.closed: + await self.__session.close() + + async def __aenter__(self) -> "DBLClient": + return self + + async def __aexit__(self, *_, **__) -> None: + await self.close() \ No newline at end of file diff --git a/topgg/errors.py b/topgg/errors.py index d8c157a5..d110126e 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -1,37 +1,34 @@ -# -*- coding: utf-8 -*- - -# The MIT License (MIT) - -# Copyright (c) 2021 Assanali Mukhanov - -# Permission is hereby granted, free of charge, to any person obtaining a -# copy of this software and associated documentation files (the "Software"), -# to deal in the Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, distribute, sublicense, -# and/or sell copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. +""" +The MIT License (MIT) + +Copyright (c) 2021 Assanali Mukhanov & Top.gg +Copyright (c) 2024-2025 null8626 & Top.gg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" __all__ = [ "TopGGException", "ClientException", "ClientStateException", + "Ratelimited", "HTTPException", - "Unauthorized", - "UnauthorizedDetected", - "Forbidden", - "NotFound", - "ServerError", ] from typing import TYPE_CHECKING, Union @@ -58,6 +55,22 @@ class ClientStateException(ClientException): """Exception that's thrown when an operation happens in a closed :obj:`~.DBLClient` instance.""" +class Ratelimited(TopGGException): + """Exception that's thrown when the client is ratelimited.""" + + __slots__: tuple[str, ...] = ('retry_after',) + + retry_after: float + """How long the client should wait in seconds before it could send requests again without receiving a 429.""" + + def __init__(self, retry_after: float): + self.retry_after = retry_after + + super().__init__( + f'Blocked from sending more requests, try again in {retry_after} seconds.' + ) + + class HTTPException(TopGGException): """Exception that's thrown when an HTTP request operation fails. @@ -66,38 +79,21 @@ class HTTPException(TopGGException): The response of the failed HTTP request. text (str) The text of the error. Could be an empty string. + code (int) + The response status code. """ + __slots__ = ("response", "text", "code") + def __init__(self, response: "ClientResponse", message: Union[dict, str]) -> None: self.response = response - if isinstance(message, dict): - self.text = message.get("message", "") - self.code = message.get("code", 0) - else: - self.text = message + self.code = response.status + self.text = message.get("message", message.get("detail", "")) if isinstance(message, dict) else message fmt = f"{self.response.reason} (status code: {self.response.status})" + if self.text: fmt = f"{fmt}: {self.text}" super().__init__(fmt) - -class Unauthorized(HTTPException): - """Exception that's thrown when status code 401 occurs.""" - - -class UnauthorizedDetected(TopGGException): - """Exception that's thrown when no API Token is provided.""" - - -class Forbidden(HTTPException): - """Exception that's thrown when status code 403 occurs.""" - - -class NotFound(HTTPException): - """Exception that's thrown when status code 404 occurs.""" - - -class ServerError(HTTPException): - """Exception that's thrown when Top.gg returns "Server Error" responses (status codes such as 500 and 503).""" diff --git a/topgg/http.py b/topgg/http.py deleted file mode 100644 index 08160d67..00000000 --- a/topgg/http.py +++ /dev/null @@ -1,252 +0,0 @@ -# -*- coding: utf-8 -*- - -# The MIT License (MIT) - -# Copyright (c) 2021 Assanali Mukhanov - -# Permission is hereby granted, free of charge, to any person obtaining a -# copy of this software and associated documentation files (the "Software"), -# to deal in the Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, distribute, sublicense, -# and/or sell copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. - -__all__ = ["HTTPClient"] - -import asyncio -import json -import logging -import sys -from datetime import datetime -from typing import Any, Coroutine, Dict, Iterable, List, Optional, Sequence, Union, cast - -import aiohttp -from aiohttp import ClientResponse - -from . import __version__, errors -from .ratelimiter import AsyncRateLimiter, AsyncRateLimiterManager - -_LOGGER = logging.getLogger("topgg.http") - - -async def _json_or_text( - response: ClientResponse, -) -> Union[dict, str]: - text = await response.text() - if response.headers["Content-Type"] == "application/json; charset=utf-8": - return json.loads(text) - return text - - -class HTTPClient: - """Represents an HTTP client sending HTTP requests to the Top.gg API. - - .. _aiohttp session: https://aiohttp.readthedocs.io/en/stable/client_reference.html#client-session - - Args: - token (str) - A Top.gg API Token. - - Keyword Arguments: - session: `aiohttp session`_ - The `aiohttp session`_ used for requests to the API. - **kwargs: - Arbitrary kwargs to be passed to :class:`aiohttp.ClientSession`. - """ - - def __init__( - self, - token: str, - *, - session: Optional[aiohttp.ClientSession] = None, - **kwargs: Any, - ) -> None: - self.BASE = "https://top.gg/api" - self.token = token - self._own_session = session is None - self.session: aiohttp.ClientSession = session or aiohttp.ClientSession(**kwargs) - self.global_rate_limiter = AsyncRateLimiter( - max_calls=99, period=1, callback=_rate_limit_handler - ) - self.bot_rate_limiter = AsyncRateLimiter( - max_calls=59, period=60, callback=_rate_limit_handler - ) - self.rate_limiters = AsyncRateLimiterManager( - [self.global_rate_limiter, self.bot_rate_limiter] - ) - self.user_agent = ( - f"topggpy (https://github.com/top-gg/python-sdk {__version__}) Python/" - f"{sys.version_info[0]}.{sys.version_info[1]} aiohttp/{aiohttp.__version__}" - ) - - async def request(self, method: str, endpoint: str, **kwargs: Any) -> dict: - """Handles requests to the API.""" - rate_limiters = ( - self.rate_limiters - if endpoint.startswith("/bots") - else self.global_rate_limiter - ) - url = f"{self.BASE}{endpoint}" - - if not self.token: - raise errors.UnauthorizedDetected("Top.gg API token not provided") - - headers = { - "User-Agent": self.user_agent, - "Content-Type": "application/json", - "Authorization": self.token, - } - - if "json" in kwargs: - kwargs["data"] = to_json(kwargs.pop("json")) - - kwargs["headers"] = headers - - for _ in range(2): - async with rate_limiters: # type: ignore - async with self.session.request(method, url, **kwargs) as resp: - _LOGGER.debug( - "%s %s with %s has returned %s", - method, - url, - kwargs.get("data"), - resp.status, - ) - - data = await _json_or_text(resp) - - if 300 > resp.status >= 200: - return cast(dict, data) - - elif resp.status == 429: # we are being ratelimited - fmt = "We are being ratelimited. Retrying in %.2f seconds (%.3f minutes)." - - # sleep a bit - retry_after = float(resp.headers["Retry-After"]) - mins = retry_after / 60 - _LOGGER.warning(fmt, retry_after, mins) - - # check if it's a global ratelimit (True as only 1 ratelimit atm - /api/bots) - # is_global = True - # is_global = data.get('global', False) - # if is_global: - # self._global_over.clear() - - await asyncio.sleep(retry_after) - _LOGGER.debug("Done sleeping for the ratelimit. Retrying...") - - # release the global lock now that the - # global ratelimit has passed - # if is_global: - # self._global_over.set() - _LOGGER.debug("Global ratelimit is now over.") - continue - - elif resp.status == 400: - raise errors.HTTPException(resp, data) - elif resp.status == 401: - raise errors.Unauthorized(resp, data) - elif resp.status == 403: - raise errors.Forbidden(resp, data) - elif resp.status == 404: - raise errors.NotFound(resp, data) - elif resp.status >= 500: - raise errors.ServerError(resp, data) - - # We've run out of retries, raise. - raise errors.HTTPException(resp, data) - - async def close(self) -> None: - if self._own_session: - await self.session.close() - - async def post_guild_count( - self, - guild_count: Optional[Union[int, List[int]]], - shard_count: Optional[int], - shard_id: Optional[int], - ) -> None: - """Posts bot's guild count and shards info on Top.gg.""" - payload = {"server_count": guild_count} - if shard_count: - payload["shard_count"] = shard_count - if shard_id: - payload["shard_id"] = shard_id - - await self.request("POST", "/bots/stats", json=payload) - - def get_weekend_status(self) -> Coroutine[Any, Any, dict]: - """Gets the weekend status from Top.gg.""" - return self.request("GET", "/weekend") - - def get_guild_count(self, bot_id: int) -> Coroutine[Any, Any, dict]: - """Gets the guild count of the given Bot ID.""" - return self.request("GET", f"/bots/{bot_id}/stats") - - def get_bot_info(self, bot_id: int) -> Coroutine[Any, Any, dict]: - """Gets the information of a bot under given bot ID on Top.gg.""" - return self.request("GET", f"/bots/{bot_id}") - - def get_bot_votes(self, bot_id: int) -> Coroutine[Any, Any, Iterable[dict]]: - """Gets your bot's last 1000 votes on Top.gg.""" - return self.request("GET", f"/bots/{bot_id}/votes") - - def get_bots( - self, - limit: int, - offset: int, - sort: str, - search: Dict[str, str], - fields: Sequence[str], - ) -> Coroutine[Any, Any, dict]: - """Gets an object of bots on Top.gg.""" - limit = min(limit, 500) - fields = ", ".join(fields) - search = " ".join([f"{field}: {value}" for field, value in search.items()]) - - return self.request( - "GET", - "/bots", - params={ - "limit": limit, - "offset": offset, - "sort": sort, - "search": search, - "fields": fields, - }, - ) - - def get_user_info(self, user_id: int) -> Coroutine[Any, Any, dict]: - """Gets an object of the user on Top.gg.""" - return self.request("GET", f"/users/{user_id}") - - def get_user_vote(self, bot_id: int, user_id: int) -> Coroutine[Any, Any, dict]: - """Gets info whether the user has voted for your bot.""" - return self.request("GET", f"/bots/{bot_id}/check", params={"userId": user_id}) - - -async def _rate_limit_handler(until: float) -> None: - """Handles the displayed message when we are ratelimited.""" - duration = round(until - datetime.utcnow().timestamp()) - mins = duration / 60 - fmt = ( - "We have exhausted a ratelimit quota. Retrying in %.2f seconds (%.3f minutes)." - ) - _LOGGER.warning(fmt, duration, mins) - - -def to_json(obj: Any) -> str: - if json.__name__ == "ujson": - return json.dumps(obj, ensure_ascii=True) - return json.dumps(obj, separators=(",", ":"), ensure_ascii=True) diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index 028a98ee..87346418 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -1,110 +1,114 @@ -# -*- coding: utf-8 -*- - -# The MIT License (MIT) - -# Copyright (c) 2021 Assanali Mukhanov - -# Permission is hereby granted, free of charge, to any person obtaining a -# copy of this software and associated documentation files (the "Software"), -# to deal in the Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, distribute, sublicense, -# and/or sell copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. - -import asyncio -import collections -from datetime import datetime +""" +The MIT License (MIT) + +Copyright (c) 2021 Assanali Mukhanov & Top.gg +Copyright (c) 2024-2025 null8626 & Top.gg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +from collections.abc import Iterable from types import TracebackType -from typing import Any, Awaitable, Callable, List, Optional, Type +from collections import deque +from time import time +import asyncio +__all__ = [ + "Ratelimiter", + "Ratelimiters", +] -class AsyncRateLimiter: - """ - Provides rate limiting for an operation with a configurable number of requests for a time period. - """ +class Ratelimiter: + """Handles ratelimits for a specific endpoint.""" - __lock: asyncio.Lock - callback: Optional[Callable[[float], Awaitable[Any]]] - max_calls: int - period: float - calls: collections.deque + __slots__: tuple[str, ...] = ('__lock', '__max_calls', '__period', '__calls') def __init__( self, max_calls: int, period: float = 1.0, - callback: Optional[Callable[[float], Awaitable[Any]]] = None, ): - if period <= 0: - raise ValueError("Rate limiting period should be > 0") - if max_calls <= 0: - raise ValueError("Rate limiting number of calls should be > 0") - self.calls = collections.deque() - - self.period = period - self.max_calls = max_calls - self.callback = callback + self.__calls = deque() + self.__period = period + self.__max_calls = max_calls self.__lock = asyncio.Lock() - async def __aenter__(self) -> "AsyncRateLimiter": + async def __aenter__(self) -> 'Ratelimiter': + """Delays the request to this endpoint if it could lead to a ratelimit.""" + async with self.__lock: - if len(self.calls) >= self.max_calls: - until = datetime.utcnow().timestamp() + self.period - self._timespan - if self.callback: - asyncio.ensure_future(self.callback(until)) - sleep_time = until - datetime.utcnow().timestamp() - if sleep_time > 0: + if len(self.__calls) >= self.__max_calls: + until = time() + self.__period - self._timespan + + if (sleep_time := until - time()) > 0: await asyncio.sleep(sleep_time) + return self async def __aexit__( self, - exc_type: Type[BaseException], - exc_val: BaseException, - exc_tb: TracebackType, + _exc_type: type[BaseException], + _exc_val: BaseException, + _exc_tb: TracebackType, ) -> None: + """Stores the previous request's timestamp.""" + async with self.__lock: - # Store the last operation timestamp. - self.calls.append(datetime.utcnow().timestamp()) + self.__calls.append(time()) - while self._timespan >= self.period: - self.calls.popleft() + while self._timespan >= self.__period: + self.__calls.popleft() @property def _timespan(self) -> float: - return self.calls[-1] - self.calls[0] + """The timespan between the first call and last call.""" + + return self.__calls[-1] - self.__calls[0] -class AsyncRateLimiterManager: - rate_limiters: List[AsyncRateLimiter] +class Ratelimiters: + """Handles ratelimits for multiple endpoints.""" - def __init__(self, rate_limiters: List[AsyncRateLimiter]): - self.rate_limiters = rate_limiters + __slots__: tuple[str, ...] = ('__ratelimiters',) + + def __init__(self, ratelimiters: Iterable[Ratelimiter]): + self.__ratelimiters = ratelimiters + + async def __aenter__(self) -> 'Ratelimiters': + """Delays the request to this endpoint if it could lead to a ratelimit.""" + + for ratelimiter in self.__ratelimiters: + await ratelimiter.__aenter__() - async def __aenter__(self) -> "AsyncRateLimiterManager": - [await manager.__aenter__() for manager in self.rate_limiters] return self async def __aexit__( self, - exc_type: Type[BaseException], + exc_type: type[BaseException], exc_val: BaseException, exc_tb: TracebackType, ) -> None: + """Stores the previous request's timestamp.""" + await asyncio.gather( - *[ - manager.__aexit__(exc_type, exc_val, exc_tb) - for manager in self.rate_limiters - ] - ) + *( + ratelimiter.__aexit__(exc_type, exc_val, exc_tb) + for ratelimiter in self.__ratelimiters + ) + ) \ No newline at end of file diff --git a/topgg/version.py b/topgg/version.py new file mode 100644 index 00000000..91c27a95 --- /dev/null +++ b/topgg/version.py @@ -0,0 +1 @@ +VERSION = "3.0.0" \ No newline at end of file