diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f53af17..59a05f3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,4 +19,4 @@ jobs: - name: Install run: python -m pip install .[dev] - name: Test with pytest - run: pytest + run: pytest -x --cov=topgg --cov-report term-missing \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e522b00..d5d4a1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dev = ["mock>=5.2.0", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", "pytest-mock>=3. [project.urls] Documentation = "https://topggpy.readthedocs.io/en/latest/" -"Raw API Documentation" = "https://docs.top.gg/docs/" +"Raw API Documentation" = "https://docs.top.gg/api/v1/introduction" Repository = "https://github.com/top-gg-community/python-sdk" "Support server" = "https://discord.gg/EYHTgJX" diff --git a/ruff.toml b/ruff.toml index 05c738c..8bfcc25 100644 --- a/ruff.toml +++ b/ruff.toml @@ -4,4 +4,7 @@ indent-width = 2 docstring-code-format = true docstring-code-line-length = 88 line-ending = "lf" -quote-style = "single" \ No newline at end of file +quote-style = "single" + +[lint] +ignore = ["E722", "E731"] \ No newline at end of file diff --git a/tests/mocks/404.json b/tests/mocks/404.json index 55ad718..3510c2a 100644 --- a/tests/mocks/404.json +++ b/tests/mocks/404.json @@ -1,5 +1,5 @@ -{ - "title": "User not found", - "status": 404, - "detail": "The user was not resolved into a valid Top.gg user. The user ID might be invalid, or the user may have deleted their account." +{ + "title": "User not found", + "status": 404, + "detail": "The user was not resolved into a valid Top.gg user. The user ID might be invalid, or the user may have deleted their account." } \ No newline at end of file diff --git a/tests/mocks/get_self.json b/tests/mocks/get_self.json index e74997b..f75608b 100644 --- a/tests/mocks/get_self.json +++ b/tests/mocks/get_self.json @@ -1,16 +1,16 @@ -{ - "id": "364806029876555776", - "name": "Top.gg Lib Dev API Access", - "type": "bot", - "platform": "discord", - "headline": "API access for Top.gg Library Developers", - "tags": [ - "api", - "library", - "topgg" - ], - "votes": 4, - "votes_total": 34, - "review_score": 5, - "review_count": 2 +{ + "id": "364806029876555776", + "name": "Top.gg Lib Dev API Access", + "type": "bot", + "platform": "discord", + "headline": "API access for Top.gg Library Developers", + "tags": [ + "api", + "library", + "topgg" + ], + "votes": 4, + "votes_total": 34, + "review_score": 5, + "review_count": 2 } \ No newline at end of file diff --git a/tests/mocks/get_vote.json b/tests/mocks/get_vote.json index c6a0227..5a4a60c 100644 --- a/tests/mocks/get_vote.json +++ b/tests/mocks/get_vote.json @@ -1,5 +1,5 @@ -{ - "created_at": "2026-02-25T22:35:36.978392+00:00", - "expires_at": "2026-02-26T10:35:36.978392+00:00", - "weight": 1 +{ + "created_at": "2026-02-25T22:35:36.978392+00:00", + "expires_at": "2026-02-26T10:35:36.978392+00:00", + "weight": 1 } \ No newline at end of file diff --git a/tests/mocks/get_votes.json b/tests/mocks/get_votes.json index 5eab378..a8c84f6 100644 --- a/tests/mocks/get_votes.json +++ b/tests/mocks/get_votes.json @@ -1,33 +1,33 @@ -{ - "cursor": "", - "data": [ - { - "user_id": "800506814562787328", - "platform_id": "1461830808796139662", - "weight": 2, - "created_at": "2026-01-17T23:36:06.34732Z", - "expires_at": "2026-01-18T11:36:06.34732Z" - }, - { - "user_id": "316026718115037184", - "platform_id": "481068576363773972", - "weight": 2, - "created_at": "2026-02-20T05:43:58.392411Z", - "expires_at": "2026-02-20T17:43:58.392411Z" - }, - { - "user_id": "794153497215045632", - "platform_id": "1425259851600101457", - "weight": 2, - "created_at": "2026-02-21T18:59:20.660734Z", - "expires_at": "2026-02-22T06:59:20.660734Z" - }, - { - "user_id": "8226924471638491136", - "platform_id": "661200758510977084", - "weight": 1, - "created_at": "2026-02-25T22:35:36.978392Z", - "expires_at": "2026-02-26T10:35:36.978392Z" - } - ] +{ + "cursor": "", + "data": [ + { + "user_id": "800506814562787328", + "platform_id": "1461830808796139662", + "weight": 2, + "created_at": "2026-01-17T23:36:06.34732Z", + "expires_at": "2026-01-18T11:36:06.34732Z" + }, + { + "user_id": "316026718115037184", + "platform_id": "481068576363773972", + "weight": 2, + "created_at": "2026-02-20T05:43:58.392411Z", + "expires_at": "2026-02-20T17:43:58.392411Z" + }, + { + "user_id": "794153497215045632", + "platform_id": "1425259851600101457", + "weight": 2, + "created_at": "2026-02-21T18:59:20.660734Z", + "expires_at": "2026-02-22T06:59:20.660734Z" + }, + { + "user_id": "8226924471638491136", + "platform_id": "661200758510977084", + "weight": 1, + "created_at": "2026-02-25T22:35:36.978392Z", + "expires_at": "2026-02-26T10:35:36.978392Z" + } + ] } \ No newline at end of file diff --git a/tests/mocks/integration_create_payload.json b/tests/mocks/integration_create_payload.json index 77df6b0..44a7793 100644 --- a/tests/mocks/integration_create_payload.json +++ b/tests/mocks/integration_create_payload.json @@ -1,19 +1,19 @@ -{ - "type": "integration.create", - "data": { - "connection_id": "112402021105124", - "webhook_secret": "whs_abcd", - "project": { - "id": "1230954036934033243", - "platform": "discord", - "platform_id": "3949456393249234923", - "type": "bot" - }, - "user": { - "id": "3949456393249234923", - "platform_id": "3949456393249234923", - "name": "username", - "avatar_url": "" - } - } +{ + "type": "integration.create", + "data": { + "connection_id": "112402021105124", + "webhook_secret": "whs_abcd", + "project": { + "id": "1230954036934033243", + "platform": "discord", + "platform_id": "3949456393249234923", + "type": "bot" + }, + "user": { + "id": "3949456393249234923", + "platform_id": "3949456393249234923", + "name": "username", + "avatar_url": "" + } + } } \ No newline at end of file diff --git a/tests/mocks/integration_delete_payload.json b/tests/mocks/integration_delete_payload.json index cb44375..7f16505 100644 --- a/tests/mocks/integration_delete_payload.json +++ b/tests/mocks/integration_delete_payload.json @@ -1,6 +1,6 @@ -{ - "type": "integration.delete", - "data": { - "connection_id": "112402021105124" - } +{ + "type": "integration.delete", + "data": { + "connection_id": "112402021105124" + } } \ No newline at end of file diff --git a/tests/mocks/post_announcement.json b/tests/mocks/post_announcement.json new file mode 100644 index 0000000..2d853ff --- /dev/null +++ b/tests/mocks/post_announcement.json @@ -0,0 +1,5 @@ +{ + "title": "Version 2.0 Released!", + "content": "We just released version 2.0 with a bunch of new features and improvements.", + "created_at": "2026-03-14T15:09:26Z" +} \ No newline at end of file diff --git a/tests/mocks/test_payload.json b/tests/mocks/test_payload.json index b7a7432..933fddd 100644 --- a/tests/mocks/test_payload.json +++ b/tests/mocks/test_payload.json @@ -1,17 +1,17 @@ -{ - "type": "webhook.test", - "data": { - "user": { - "id": "160105994217586689", - "platform_id": "160105994217586689", - "name": "username", - "avatar_url": "" - }, - "project": { - "id": "803190510032756736", - "type": "bot", - "platform": "discord", - "platform_id": "160105994217586689" - } - } +{ + "type": "webhook.test", + "data": { + "user": { + "id": "160105994217586689", + "platform_id": "160105994217586689", + "name": "username", + "avatar_url": "" + }, + "project": { + "id": "803190510032756736", + "type": "bot", + "platform": "discord", + "platform_id": "160105994217586689" + } + } } \ No newline at end of file diff --git a/tests/mocks/vote_create_payload.json b/tests/mocks/vote_create_payload.json index 6850196..9f0991e 100644 --- a/tests/mocks/vote_create_payload.json +++ b/tests/mocks/vote_create_payload.json @@ -1,21 +1,21 @@ -{ - "type": "vote.create", - "data": { - "id": "808499215864008704", - "weight": 1, - "created_at": "2026-02-09T00:47:14.2510149+00:00", - "expires_at": "2026-02-09T12:47:14.2510149+00:00", - "project": { - "id": "803190510032756736", - "type": "bot", - "platform": "discord", - "platform_id": "160105994217586689" - }, - "user": { - "id": "160105994217586689", - "platform_id": "160105994217586689", - "name": "username", - "avatar_url": "" - } - } +{ + "type": "vote.create", + "data": { + "id": "808499215864008704", + "weight": 1, + "created_at": "2026-02-09T00:47:14.2510149+00:00", + "expires_at": "2026-02-09T12:47:14.2510149+00:00", + "project": { + "id": "803190510032756736", + "type": "bot", + "platform": "discord", + "platform_id": "160105994217586689" + }, + "user": { + "id": "160105994217586689", + "platform_id": "160105994217586689", + "name": "username", + "avatar_url": "" + } + } } \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py index d14c16c..a245b2a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,24 +6,29 @@ sys.path.insert(0, path.join(path.dirname(path.realpath(__file__)), '..')) -from typing import AsyncGenerator, TYPE_CHECKING +from typing import TYPE_CHECKING from collections import deque from time import time import pytest_asyncio import pytest +if TYPE_CHECKING: + from typing import AsyncGenerator + + import topgg from util import _test_attributes, RequestMock MOCK_TOKEN = '.eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=.' +MOCK_LOCALE_MAPPING = {topgg.Locale.ENGLISH: 'test', topgg.Locale.JAPANESE: 'test'} @pytest_asyncio.fixture async def client( monkeypatch: pytest.MonkeyPatch, -) -> AsyncGenerator[topgg.Client, None]: +) -> 'AsyncGenerator[topgg.Client, None]': client = topgg.Client(MOCK_TOKEN) monkeypatch.setattr(topgg.Ratelimiter, '_calls', deque([time()])) @@ -101,11 +106,81 @@ async def test_Client_get_self_works( request.assert_called_once() +@pytest.mark.asyncio +async def test_Client_edit_self_works( + monkeypatch: pytest.MonkeyPatch, + client: topgg.Client, +) -> None: + if not TYPE_CHECKING: + with pytest.raises( + TypeError, match=r'^The locale mapping\'s keys must be an instance of Locale\.$' + ): + await client.edit_self(headline={'en': 'test'}) + + with pytest.raises( + TypeError, match=r'^The locale mapping\'s keys must be an instance of Locale\.$' + ): + await client.edit_self(headline={'en': 'test'}, content=MOCK_LOCALE_MAPPING) + + with pytest.raises( + TypeError, match=r'^The locale mapping\'s keys must be an instance of Locale\.$' + ): + await client.edit_self(content={'en': 'test'}) + + with pytest.raises(ValueError, match=r'^headline or content must be specified\.$'): + await client.edit_self() + + with RequestMock(204, 'No Content') as request: + monkeypatch.setattr('aiohttp.ClientSession.request', request) + + await client.edit_self(headline=MOCK_LOCALE_MAPPING, content=MOCK_LOCALE_MAPPING) + + request.assert_called_once() + + +@pytest.mark.asyncio +async def test_Client_post_announcement_works( + monkeypatch: pytest.MonkeyPatch, + client: topgg.Client, +) -> None: + if not TYPE_CHECKING: + with pytest.raises( + TypeError, + match=r'^The specified title and content must be a string\.$', + ): + await client.post_announcement(None, 2) + + with pytest.raises( + ValueError, + match=r'^The specified title and content length must be within the accepted ranges\.$', + ): + await client.post_announcement('test', 'test') + + with RequestMock(200, 'OK', response='mocks/post_announcement.json') as request: + monkeypatch.setattr('aiohttp.ClientSession.request', request) + + announcement = await client.post_announcement( + 'Version 2.0 Released!', + 'We just released version 2.0 with a bunch of new features and improvements.', + ) + + _test_attributes(announcement) + + request.assert_called_once() + + @pytest.mark.asyncio async def test_Client_post_commands_works( monkeypatch: pytest.MonkeyPatch, client: topgg.Client, ) -> None: + if not TYPE_CHECKING: + with pytest.raises( + TypeError, + match="^The specified commands is not a list of dicts in the form of Discord API's raw JSON format.$", + ): + await client.post_commands(None) + with RequestMock(204, 'No Content') as request: monkeypatch.setattr('aiohttp.ClientSession.request', request) @@ -125,12 +200,59 @@ async def test_Client_post_commands_works( request.assert_called_once() + +@pytest.mark.asyncio +async def test_Client_post_metrics_works( + monkeypatch: pytest.MonkeyPatch, + client: topgg.Client, +) -> None: if not TYPE_CHECKING: with pytest.raises( TypeError, - match="^The specified commands is not a list of dicts in the form of Discord API's raw JSON format.$", + match=r'^The specified metrics has an invalid type\.$', ): - await client.post_commands(None) + await client.post_metrics('test') + + with pytest.raises( + TypeError, match=r'^The specified player count must be an integer\.$' + ): + await client.post_metrics(topgg.Metrics.roblox_game(None)) + + with pytest.raises( + ValueError, + match=r'^The specified batch of metrics must not be empty\.$', + ): + await client.post_metrics({}) + + with pytest.raises( + TypeError, + match=r'^The specified server count and\/or shard count must be an integer\.$', + ): + await client.post_metrics(topgg.Metrics.discord_bot()) + + with pytest.raises( + TypeError, + match=r'^The specified member count and\/or online count must be an integer\.$', + ): + await client.post_metrics(topgg.Metrics.discord_server()) + + with RequestMock(204, 'No Content') as request: + monkeypatch.setattr('aiohttp.ClientSession.request', request) + + await client.post_metrics(topgg.Metrics.discord_bot(server_count=1, shard_count=1)) + await client.post_metrics(topgg.Metrics.discord_bot(server_count=1)) + + await client.post_metrics( + topgg.Metrics.discord_server(member_count=1, online_count=1) + ) + await client.post_metrics(topgg.Metrics.discord_server(member_count=1)) + + metrics = topgg.Metrics.roblox_game(1) + + await client.post_metrics(metrics) + await client.post_metrics({datetime.now(): metrics}) + + assert request.call_count == 6 @pytest.mark.asyncio @@ -138,6 +260,12 @@ async def test_Client_get_vote_works( monkeypatch: pytest.MonkeyPatch, client: topgg.Client, ) -> None: + if not TYPE_CHECKING: + with pytest.raises( + TypeError, match="^The specified user's source and/or ID's type is invalid.$" + ): + await client.get_vote(topgg.UserSource.DISCORD, None) + with RequestMock(200, 'OK', response='mocks/get_vote.json') as request: monkeypatch.setattr('aiohttp.ClientSession.request', request) @@ -169,18 +297,18 @@ async def test_Client_get_vote_works( _test_attributes(raises.value) request.assert_called_once() - if not TYPE_CHECKING: - with pytest.raises( - TypeError, match="^The specified user's source and/or ID's type is invalid.$" - ): - await client.get_vote(topgg.UserSource.DISCORD, None) - @pytest.mark.asyncio async def test_Client_get_votes_works( monkeypatch: pytest.MonkeyPatch, client: topgg.Client, ) -> None: + if not TYPE_CHECKING: + with pytest.raises( + TypeError, match=r'The specified earliest possible date\'s type is invalid.$' + ): + await client.get_votes(None) + with RequestMock(200, 'OK', response='mocks/get_votes.json') as request: monkeypatch.setattr('aiohttp.ClientSession.request', request) @@ -193,9 +321,3 @@ async def test_Client_get_votes_works( _test_attributes(second_page) assert request.call_count == 2 - - if not TYPE_CHECKING: - with pytest.raises( - TypeError, match=r'The specified earliest possible date\'s type is invalid.$' - ): - await client.get_votes(None) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index f1c2c25..f805442 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -1,4 +1,4 @@ -from collections.abc import AsyncGenerator, Callable +from collections.abc import Callable from aiohttp import test_utils, web from typing import TYPE_CHECKING from functools import cache @@ -9,6 +9,9 @@ import pytest import hmac +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + import topgg from util import _test_attributes, CURRENT_DIR @@ -21,7 +24,7 @@ @pytest_asyncio.fixture -async def webhooks() -> AsyncGenerator[WebhooksFixture, None]: +async def webhooks() -> 'AsyncGenerator[WebhooksFixture, None]': app = test_utils.TestClient(test_utils.TestServer(web.Application())) webhooks = topgg.Webhooks('/webhook', MOCK_SECRET, app=app) @@ -62,7 +65,7 @@ async def test_Webhooks_error_handling_works( if not TYPE_CHECKING: with pytest.raises( TypeError, - match='^The specified secret, route, and/or host must be a valid string.$', + match='^The specified secret, route, and/or host must be a string.$', ): topgg.Webhooks(None, None) @@ -85,9 +88,7 @@ async def test_Webhooks_error_handling_works( _test_attributes(wh) if not TYPE_CHECKING: - with pytest.raises( - TypeError, match=r'^The specified secret must be a valid string.$' - ): + with pytest.raises(TypeError, match=r'^The specified secret must be a string.$'): wh.secret = 5 with pytest.raises(TypeError, match="^The specified payload's type is invalid.$"): diff --git a/tests/util.py b/tests/util.py index 11be104..1e9da92 100644 --- a/tests/util.py +++ b/tests/util.py @@ -1,6 +1,6 @@ from multidict import CIMultiDict, CIMultiDictProxy -from typing import Any, TYPE_CHECKING from contextlib import nullcontext +from typing import TYPE_CHECKING from inspect import getmembers from sys import stdout from yarl import URL @@ -11,6 +11,7 @@ if TYPE_CHECKING: from io import TextIOWrapper + from typing import Any CURRENT_DIR = path.dirname(path.realpath(__file__)) @@ -158,7 +159,7 @@ def __init__( def __enter__(self) -> mock.Mock: return mock.Mock(return_value=nullcontext(self.__mock_response)) - def __exit__(self, *_: Any) -> None: + def __exit__(self, *_: 'Any') -> None: if self.__mock_json_response is not None: self.__mock_json_response.close() self.__mock_json_response = None diff --git a/topgg/__init__.py b/topgg/__init__.py index 1271f4f..97a8042 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -19,11 +19,19 @@ VoteCreatePayload, ) from .user import PaginatedVotes, PartialVote, User, UserSource, Vote -from .project import PartialProject, Platform, Project, ProjectType -from .client import API_VERSION, BASE_URL, Client +from .project import ( + Announcement, + Metrics, + PartialProject, + Platform, + Project, + ProjectType, +) from .errors import Error, Ratelimited, RequestError +from .client import API_VERSION, BASE_URL, Client from .ratelimiter import Ratelimiter from .version import VERSION +from .locale import Locale from .widget import Widget @@ -36,6 +44,7 @@ __copyright__ = 'Copyright (c) 2024-2026 null8626 & Top.gg' __version__ = VERSION __all__ = ( + 'Announcement', 'API_VERSION', 'BASE_URL', 'Client', @@ -45,6 +54,8 @@ 'IntegrationDeleteListener', 'IntegrationDeletePayload', 'Listener', + 'Locale', + 'Metrics', 'PaginatedVotes', 'PartialProject', 'PartialVote', diff --git a/topgg/client.py b/topgg/client.py index a0e9433..edcb1c8 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -3,19 +3,24 @@ # SPDX-FileCopyrightText: 2024-2026 null8626 & Top.gg from aiohttp import ClientResponseError, ClientSession, ClientTimeout +from typing import TYPE_CHECKING from datetime import datetime from asyncio import sleep -from typing import Any -from yarl import Query from time import time from re import sub import json +if TYPE_CHECKING: + from typing import Any + from yarl import Query + from .user import PaginatedVotes, PartialVote, UserSource from .errors import Error, Ratelimited, RequestError +from .project import Announcement, Metrics, Project +from .util import insert_locale_mapping from .ratelimiter import Ratelimiter -from .project import Project from .version import VERSION +from .locale import Locale API_VERSION = 'v1' @@ -63,10 +68,13 @@ def __init__(self, token: str, *, session: ClientSession | None = None): self.__token = token endpoint_ratelimits = { - 'projects_@me': Ratelimiter(99, 60), - 'projects_@me_commands': Ratelimiter(99, 60), - 'projects_@me_votes_number': Ratelimiter(99, 60), - 'projects_@me_votes': Ratelimiter(99, 60), + 'projects_@me': Ratelimiter(99), + 'projects_@me_announcements': Ratelimiter(1, 14400), + 'projects_@me_commands': Ratelimiter(99), + 'projects_@me_metrics': Ratelimiter(99), + 'projects_@me_metrics_batch': Ratelimiter(99), + 'projects_@me_votes_number': Ratelimiter(99), + 'projects_@me_votes': Ratelimiter(99), } self.__ratelimiters = endpoint_ratelimits @@ -76,8 +84,8 @@ def __repr__(self) -> str: return f'<{__class__.__name__} {self.__session!r}>' async def __request( - self, method: str, path: str, *, params: Query = None, body: Any = None - ) -> Any: + self, method: str, path: str, *, params: 'Query' = None, body: 'Any' = None + ) -> 'Any': if self.__session.closed: raise Error('Client session is already closed.') @@ -124,8 +132,10 @@ async def __request( status = resp.status try: - if method == 'GET': + try: output = await resp.json() + except: + pass retry_after = float(resp.headers.get('Retry-After', 0)) except (ValueError, json.decoder.JSONDecodeError): # pragma: nocover @@ -161,7 +171,71 @@ async def get_self(self) -> Project: return Project(await self.__request('GET', '/projects/@me')) - async def post_commands(self, commands: list[dict]): + async def edit_self( + self, *, headline: dict[Locale, str] = {}, content: dict[Locale, str] = {} + ) -> None: + """ + Tries to update your project's information. + + :param headline: A locale mapping of your project's headline. + :type headline: dict[:class:`.Locale`, :py:class:`str`] + :param content: A locale mapping of your project's page content. + :type content: dict[:class:`.Locale`, :py:class:`str`] + + :exception Error: The client is already closed. + :exception TypeError: The headline and/or content's keys are not an instance of :class:`.Locale`. + :exception ValueError: The headline and content are left unspecified. + :exception RequestError: The specified bot does not exist or the client has received other non-favorable responses from the API. + :exception Ratelimited: Ratelimited from sending more requests. + """ + + body = {} + + if headline: + insert_locale_mapping('headline', headline, body) + + if content: + insert_locale_mapping('content', content, body) + elif not headline: + raise ValueError('headline or content must be specified.') + + await self.__request('PATCH', '/projects/@me', body=body) + + async def post_announcement(self, title: str, content: str) -> Announcement: + """ + Tries to create a new announcement for your project. Announcements appear on your project's page and can be used to notify users about updates, new features, or other news. + + :param title: The announcement's title. + :type title: :py:class:`str` + :param content: The announcement's content. + :type content: :py:class:`str` + + :exception TypeError: The specified title and content is not a :py:class:`str`. + :exception ValueError: The specified title and content length is not within the accepted ranges. + :exception Error: The client is already closed. + :exception RequestError: The specified bot does not exist or the client has received other non-favorable responses from the API. + :exception Ratelimited: Ratelimited from sending more requests. + + :returns: The created announcement. + :rtype: :class:`.Announcement` + """ + + if not (isinstance(title, str) and isinstance(content, str)): + raise TypeError('The specified title and content must be a string.') + elif len(title) < 3 or len(content) < 10: + raise ValueError( + 'The specified title and content length must be within the accepted ranges.' + ) + + return Announcement( + await self.__request( + 'POST', + '/projects/@me/announcements', + body={'title': title[100:], 'content': content[2000:]}, + ) + ) + + async def post_commands(self, commands: list[dict]) -> None: """ Tries to update the application commands list in your Discord bot's Top.gg page. @@ -183,6 +257,46 @@ async def post_commands(self, commands: list[dict]): await self.__request('POST', '/projects/@me/commands', body=commands) + async def post_metrics(self, metrics: Metrics | dict[datetime, Metrics]) -> None: + """ + Tries to post a single or batch of metrics payloads for your project. Use this to push fresh numbers after an event such as joining or leaving a guild or a player connecting. + + :param metrics: A single or batch of metrics. + :type metrics: :class:`.Metrics` | dict[:py:class:`~datetime.datetime`, :class:`.Metrics`] + + :exception TypeError: The specified metrics has an invalid type. + :exception ValueError: The specified batch of metrics is empty. + :exception Error: The client is already closed. + :exception RequestError: The specified bot does not exist or the client has received other non-favorable responses from the API. + :exception Ratelimited: Ratelimited from sending more requests. + """ + + if not isinstance(metrics, Metrics) and not ( + isinstance(metrics, dict) + and all( + isinstance(timestamp, datetime) and isinstance(metrics, Metrics) + for timestamp, metrics in metrics.items() + ) + ): + raise TypeError('The specified metrics has an invalid type.') + elif not metrics: + raise ValueError('The specified batch of metrics must not be empty.') + elif isinstance(metrics, Metrics): + await self.__request( + 'PATCH', + '/projects/@me/metrics', + body=metrics._json, + ) + else: + await self.__request( + 'POST', + '/projects/@me/metrics/batch', + body=[ + {'timestamp': timestamp.isoformat(), 'metrics': metrics._json} + for timestamp, metrics in metrics.items() + ], + ) + async def get_vote(self, user_source: UserSource, id: int) -> PartialVote | None: """ Tries to get the latest vote information of a user on your project. Returns :py:obj:`None` if the user has not voted. diff --git a/topgg/errors.py b/topgg/errors.py index 0a05271..088e3e5 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -2,7 +2,11 @@ # SPDX-FileCopyrightText: 2021-2024 Assanali Mukhanov & Top.gg # SPDX-FileCopyrightText: 2024-2026 null8626 & Top.gg -from typing import Any +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any class Error(Exception): @@ -11,40 +15,33 @@ class Error(Exception): __slots__: tuple[str, ...] = () +@dataclass(frozen=True, repr=False, slots=True) class RequestError(Error): """Thrown upon HTTP request failure. Extends :class:`~.errors.Error`.""" - __slots__: tuple[str, ...] = ('data', 'status') - - data: Any + data: 'Any' """The JSON error data returned from the API.""" status: int | None """The status code returned from the API.""" - def __init__(self, data: Any, status: int | None): - self.data = data - self.status = status - - super().__init__(f'Got {status}: {data!r}') + def __post_init__(self) -> None: + super(Error, self).__init__(f'Got {self.status}: {self.data!r}') def __repr__(self) -> str: return f'<{__class__.__name__} data={self.data!r} status={self.status}>' +@dataclass(frozen=True, repr=False, slots=True) class Ratelimited(Error): """Thrown upon HTTP request failure due to the client being ratelimited. Because of this, the client is not allowed to make requests for a period of time. Extends :class:`~.errors.Error`.""" - __slots__: tuple[str, ...] = ('retry_after',) - retry_after: float """How long the client should wait (in seconds) until it can make a request to the API again.""" - def __init__(self, retry_after: float): - self.retry_after = retry_after - - super().__init__( - f'The client is blocked by the API. Please try again in {retry_after} seconds.' + def __post_init__(self) -> None: + super(Error, self).__init__( + f'The client is blocked by the API. Please try again in {self.retry_after} seconds.' ) def __repr__(self) -> str: diff --git a/topgg/locale.py b/topgg/locale.py new file mode 100644 index 0000000..e187bed --- /dev/null +++ b/topgg/locale.py @@ -0,0 +1,24 @@ +from enum import Enum + + +class Locale(Enum): + """A supported locale.""" + + __slots__: tuple[str, ...] = () + + ENGLISH = 'en' + GERMAN = 'de' + FRENCH = 'fr' + PORTUGUESE = 'pt' + TURKISH = 'tr' + HINDI = 'hi' + JAPANESE = 'ja' + ARABIC = 'ar' + DUTCH = 'nl' + KOREAN = 'ko' + ITALIAN = 'it' + SPANISH = 'es' + RUSSIAN = 'ru' + UKRAINIAN = 'uk' + VIETNAMESE = 'vi' + CHINESE = 'zh' diff --git a/topgg/project.py b/topgg/project.py index 6670125..250f046 100644 --- a/topgg/project.py +++ b/topgg/project.py @@ -1,13 +1,18 @@ # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: 2026 null8626 & Top.gg +from dataclasses import dataclass +from datetime import datetime from enum import Enum +from .util import parse_timestamp, safe_dict + class Platform(Enum): """A project's platform.""" DISCORD = 'discord' + ROBLOX = 'roblox' class ProjectType(Enum): @@ -15,6 +20,7 @@ class ProjectType(Enum): BOT = 'bot' SERVER = 'server' + GAME = 'game' class PartialProject: @@ -116,3 +122,72 @@ def __int__(self) -> int: def __eq__(self, other: object) -> bool: return isinstance(other, __class__) and self.id == other.id + + +class Announcement: + """A project's announcement.""" + + __slots__: tuple[str, ...] = ('title', 'content', 'created_at') + + title: str + """The announcement's title.""" + + content: str + """The announcement's content.""" + + created_at: datetime + """When the announcement was created.""" + + def __init__(self, json: dict): + self.title = json['title'] + self.content = json['content'] + self.created_at = parse_timestamp(json['created_at']) + + def __repr__(self) -> str: + return f'<{__class__.__name__} title={self.title!r} content={self.content!r}>' + + +@dataclass(frozen=True, repr=False, slots=True) +class Metrics: + """A project metrics.""" + + _json: dict + + @staticmethod + def discord_bot( + server_count: int | None = None, shard_count: int | None = None + ) -> 'Metrics': + """Creates a new Discord bot metrics.""" + + if not (server_count or shard_count) or not ( + isinstance(server_count, int) or isinstance(shard_count, int) + ): + raise TypeError( + 'The specified server count and/or shard count must be an integer.' + ) + + return Metrics(safe_dict(server_count=server_count, shard_count=shard_count)) + + @staticmethod + def discord_server( + member_count: int | None = None, online_count: int | None = None + ) -> 'Metrics': + """Creates a new Discord server metrics.""" + + if not (member_count or online_count) or not ( + isinstance(member_count, int) or isinstance(online_count, int) + ): + raise TypeError( + 'The specified member count and/or online count must be an integer.' + ) + + return Metrics(safe_dict(member_count=member_count, online_count=online_count)) + + @staticmethod + def roblox_game(player_count: int) -> 'Metrics': + """Creates a new Roblox game metrics.""" + + if not isinstance(player_count, int): + raise TypeError('The specified player count must be an integer.') + + return Metrics(safe_dict(player_count=player_count)) diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index 1a1aab8..f50d200 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -2,44 +2,42 @@ # SPDX-FileCopyrightText: 2021-2024 Assanali Mukhanov & Top.gg # SPDX-FileCopyrightText: 2024-2026 null8626 & Top.gg +from dataclasses import dataclass, field +from typing import TYPE_CHECKING from collections import deque from time import time import asyncio -import typing -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from types import TracebackType -class Ratelimiter: - """Handles ratelimits for a specific endpoint.""" +MAXIMUM_DELAY_THRESHOLD = 5 * 60 - __slots__: tuple[str, ...] = ('_calls', '__period', '__max_calls', '__lock') - _calls: deque[float] - __period: float - __max_calls: int - __lock: asyncio.Lock +@dataclass(repr=False, slots=True) +class Ratelimiter: + """Handles ratelimits for a specific endpoint.""" - def __init__( - self, - max_calls: int, - period: float = 1.0, - ): - self._calls = deque() - self.__period = period - self.__max_calls = max_calls - self.__lock = asyncio.Lock() + _max_calls: int + _period: float = 1.0 + _calls: deque[float] = field(default_factory=deque) + _lock: asyncio.Lock = asyncio.Lock() + _cancelled_delay: bool = field(default=False, init=False) 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 = time() + self.__period - self._timespan + async with self._lock: + if len(self._calls) >= self._max_calls: + now = time() + until = now + self._period - self._timespan - if (sleep_time := until - time()) > 0: - await asyncio.sleep(sleep_time) + if (sleep_time := until - now) > 0: + if sleep_time <= (MAXIMUM_DELAY_THRESHOLD): + await asyncio.sleep(sleep_time) + else: + self._cancelled_delay = True return self @@ -51,11 +49,14 @@ async def __aexit__( ) -> None: """Stores the previous request's timestamp.""" - async with self.__lock: - self._calls.append(time()) + async with self._lock: + if self._cancelled_delay: + self._cancelled_delay = False + else: + 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: diff --git a/topgg/user.py b/topgg/user.py index 34d5d1e..927a832 100644 --- a/topgg/user.py +++ b/topgg/user.py @@ -1,15 +1,16 @@ # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: 2026 null8626 & Top.gg -from collections.abc import Iterator from typing import TYPE_CHECKING from datetime import datetime from enum import Enum +from .util import parse_timestamp + if TYPE_CHECKING: - from .client import Client + from collections.abc import Iterator -from .util import parse_timestamp + from .client import Client class UserSource(Enum): @@ -106,7 +107,7 @@ def __repr__(self) -> str: def __len__(self) -> int: return len(self.__votes) - def __iter__(self) -> Iterator[Vote]: + def __iter__(self) -> 'Iterator[Vote]': return iter(self.__votes) diff --git a/topgg/util.py b/topgg/util.py index 5c3f55f..f0fc0e5 100644 --- a/topgg/util.py +++ b/topgg/util.py @@ -1,9 +1,15 @@ # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: 2026 null8626 & Top.gg +from typing import TYPE_CHECKING from datetime import datetime from sys import version_info +if TYPE_CHECKING: + from typing import Any + +from .locale import Locale + if version_info.major == 3 and version_info.minor <= 10: # pragma: nocover from re import compile @@ -21,3 +27,21 @@ def parse_timestamp(timestamp: str) -> datetime: ) return datetime.fromisoformat(timestamp.replace('Z', '+00:00')) + + +def safe_dict(**kwargs: 'Any') -> dict: + """Creates a new dictionary from a set of keyword arguments where None properties are not included.""" + + return {key: value for key, value in kwargs.items() if value is not None} + + +def insert_locale_mapping(name: str, mapping: dict[Locale, str], output: dict) -> None: + """Inserts a locale mapping to a dictionary.""" + + output[name] = {} + + for locale, value in mapping.items(): + if not isinstance(locale, Locale): + raise TypeError("The locale mapping's keys must be an instance of Locale.") + + output[name][locale.value] = value diff --git a/topgg/webhooks.py b/topgg/webhooks.py index 81a44ee..c95ef75 100644 --- a/topgg/webhooks.py +++ b/topgg/webhooks.py @@ -6,13 +6,17 @@ from asyncio import wait_for, TimeoutError from inspect import iscoroutinefunction from aiohttp import test_utils, web -from typing import Any, TypeAlias +from typing import TYPE_CHECKING from hashlib import sha256 from time import time import warnings import hmac import json +if TYPE_CHECKING: + from typing import Any, TypeAlias + +from .client import API_VERSION from .payload import ( IntegrationCreatePayload, IntegrationDeletePayload, @@ -20,28 +24,27 @@ PayloadType, VoteCreatePayload, ) -from .client import API_VERSION -IntegrationCreateListener: TypeAlias = Callable[ +IntegrationCreateListener: 'TypeAlias' = Callable[ [IntegrationCreatePayload, str], Awaitable[web.Response] ] """Fires when a user has connected to your webhook integration.""" -IntegrationDeleteListener: TypeAlias = Callable[ +IntegrationDeleteListener: 'TypeAlias' = Callable[ [IntegrationDeletePayload, str], Awaitable[web.Response | None] ] """Fires when a user has disconnected from your webhook integration.""" -TestListener: TypeAlias = Callable[[TestPayload, str], Awaitable[web.Response | None]] +TestListener: 'TypeAlias' = Callable[[TestPayload, str], Awaitable[web.Response | None]] """Fires upon sent test from the project dashboard.""" -VoteCreateListener: TypeAlias = Callable[ +VoteCreateListener: 'TypeAlias' = Callable[ [VoteCreatePayload, str], Awaitable[web.Response | None] ] """Fires when a user votes for your project.""" -Listener: TypeAlias = ( +Listener: 'TypeAlias' = ( IntegrationCreateListener | IntegrationDeleteListener | TestListener @@ -114,9 +117,7 @@ def __init__( or not isinstance(route, str) or not isinstance(host, str) ): - raise TypeError( - 'The specified secret, route, and/or host must be a valid string.' - ) + raise TypeError('The specified secret, route, and/or host must be a string.') elif not secret or not route or not host: raise ValueError('The specified secret, route, and/or host must not be empty.') elif port is not None and not isinstance(port, int): @@ -160,7 +161,7 @@ def secret(self, new_secret: str): """ if not isinstance(new_secret, str): - raise TypeError('The specified secret must be a valid string.') + raise TypeError('The specified secret must be a string.') elif not new_secret: raise ValueError('The specified secret must not be empty.') @@ -173,7 +174,7 @@ def on(self, payload_type: PayloadType) -> 'Callable[[Listener], Listener]': :param payload_type: The corresponding webhook payload type. :type payload_type: :class:`.PayloadType` - :exception TypeError: The specified payload type and/or listener's type is invalid. + :exception TypeError: The specified payload type is invalid. :exception UnicodeDecodeError: The specified secret is not valid UTF-8. :returns: A decorator of the specified listener. @@ -215,7 +216,7 @@ async def handler(request: web.Request) -> web.Response: body = None payload_type = None - payload: Any = None + payload: 'Any' = None try: assert request.body_exists and request.has_body and request.can_read_body diff --git a/topgg/widget.py b/topgg/widget.py index 25465e1..73a3c90 100644 --- a/topgg/widget.py +++ b/topgg/widget.py @@ -1,8 +1,13 @@ # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: 2026 null8626 & Top.gg -from .project import Platform, ProjectType +from typing import TYPE_CHECKING + from .client import BASE_URL +from .project import ProjectType + +if TYPE_CHECKING: + from .project import Platform class Widget: @@ -11,7 +16,7 @@ class Widget: __slots__: tuple[str, ...] = () @staticmethod - def large(platform: Platform, project_type: ProjectType, id: int) -> str: + def large(platform: 'Platform', project_type: ProjectType, id: int) -> str: """ Generates a large widget URL. @@ -36,7 +41,7 @@ def large(platform: Platform, project_type: ProjectType, id: int) -> str: return f'{BASE_URL}/widgets/large/{platform.value}/{project_type.value}/{id}' @staticmethod - def votes(platform: Platform, project_type: ProjectType, id: int) -> str: + def votes(platform: 'Platform', project_type: ProjectType, id: int) -> str: """ Generates a small widget URL for displaying votes. @@ -61,7 +66,7 @@ def votes(platform: Platform, project_type: ProjectType, id: int) -> str: return f'{BASE_URL}/widgets/small/votes/{platform.value}/{project_type.value}/{id}' @staticmethod - def owner(platform: Platform, project_type: ProjectType, id: int) -> str: + def owner(platform: 'Platform', project_type: ProjectType, id: int) -> str: """ Generates a small widget URL for displaying a project's owner. @@ -86,7 +91,7 @@ def owner(platform: Platform, project_type: ProjectType, id: int) -> str: return f'{BASE_URL}/widgets/small/owner/{platform.value}/{project_type.value}/{id}' @staticmethod - def social(platform: Platform, project_type: ProjectType, id: int) -> str: + def social(platform: 'Platform', project_type: ProjectType, id: int) -> str: """ Generates a small widget URL for displaying social stats.