From 766fb6982de723ca169e1ec0422cf1c1d5d8bdbd Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:19:09 +0700 Subject: [PATCH 01/11] refactor: turn some classes to dataclasses --- topgg/errors.py | 22 ++++++++-------------- topgg/ratelimiter.py | 33 ++++++++++++--------------------- 2 files changed, 20 insertions(+), 35 deletions(-) diff --git a/topgg/errors.py b/topgg/errors.py index 0a05271..2e0bf51 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: 2021-2024 Assanali Mukhanov & Top.gg # SPDX-FileCopyrightText: 2024-2026 null8626 & Top.gg +from dataclasses import dataclass from typing import Any @@ -11,40 +12,33 @@ class Error(Exception): __slots__: tuple[str, ...] = () +@dataclass(repr=False, slots=True) class RequestError(Error): """Thrown upon HTTP request failure. Extends :class:`~.errors.Error`.""" - __slots__: tuple[str, ...] = ('data', 'status') - 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(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/ratelimiter.py b/topgg/ratelimiter.py index 1a1aab8..8efdb0e 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -2,6 +2,8 @@ # SPDX-FileCopyrightText: 2021-2024 Assanali Mukhanov & Top.gg # SPDX-FileCopyrightText: 2024-2026 null8626 & Top.gg + +from dataclasses import dataclass from collections import deque from time import time import asyncio @@ -11,32 +13,21 @@ from types import TracebackType +@dataclass(repr=False, slots=True) class Ratelimiter: """Handles ratelimits for a specific endpoint.""" - __slots__: tuple[str, ...] = ('_calls', '__period', '__max_calls', '__lock') - - _calls: deque[float] - __period: float - __max_calls: int - __lock: asyncio.Lock - - 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] = deque() + _lock: asyncio.Lock = asyncio.Lock() 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: + until = time() + self._period - self._timespan if (sleep_time := until - time()) > 0: await asyncio.sleep(sleep_time) @@ -51,10 +42,10 @@ async def __aexit__( ) -> None: """Stores the previous request's timestamp.""" - async with self.__lock: + async with self._lock: self._calls.append(time()) - while self._timespan >= self.__period: + while self._timespan >= self._period: self._calls.popleft() @property From cfcfc521deb42f036c1e3278700162ebc2c6e442 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:33:54 +0700 Subject: [PATCH 02/11] feat: add edit_self --- .github/workflows/test.yml | 2 +- tests/test_client.py | 33 ++++++++++++++++++++++++ topgg/__init__.py | 3 ++- topgg/client.py | 53 +++++++++++++++++++++++++++++++++++--- topgg/project.py | 23 +++++++++++++++++ topgg/ratelimiter.py | 4 +-- 6 files changed, 110 insertions(+), 8 deletions(-) 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/tests/test_client.py b/tests/test_client.py index d14c16c..b99ec53 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -18,6 +18,7 @@ MOCK_TOKEN = '.eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=.' +MOCK_LOCALE_MAPPING = {topgg.Locale.ENGLISH: 'test', topgg.Locale.JAPANESE: 'test'} @pytest_asyncio.fixture @@ -101,6 +102,38 @@ 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 headline\'s keys must be an instance of Locale\.$' + ): + await client.edit_self(headline={'en': 'test'}) + + with pytest.raises( + TypeError, match=r'^The headline\'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 content\'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_commands_works( monkeypatch: pytest.MonkeyPatch, diff --git a/topgg/__init__.py b/topgg/__init__.py index 1271f4f..f291777 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -19,7 +19,7 @@ VoteCreatePayload, ) from .user import PaginatedVotes, PartialVote, User, UserSource, Vote -from .project import PartialProject, Platform, Project, ProjectType +from .project import Locale, PartialProject, Platform, Project, ProjectType from .client import API_VERSION, BASE_URL, Client from .errors import Error, Ratelimited, RequestError from .ratelimiter import Ratelimiter @@ -45,6 +45,7 @@ 'IntegrationDeleteListener', 'IntegrationDeletePayload', 'Listener', + 'Locale', 'PaginatedVotes', 'PartialProject', 'PartialVote', diff --git a/topgg/client.py b/topgg/client.py index a0e9433..114e87d 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -3,18 +3,21 @@ # 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 .ratelimiter import Ratelimiter -from .project import Project +from .project import Locale, Project from .version import VERSION @@ -161,7 +164,49 @@ 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: + """ + Updates your project's information. + + :param headline: A locale mapping of your project's headline. + :type headline: list[:py:class:`dict`] + :param content: A locale mapping of your project's page content. + :type content: list[:py:class:`dict`] + + :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: + body['headline'] = {} + + for key, value in headline.items(): + if not isinstance(key, Locale): + raise TypeError("The headline's keys must be an instance of Locale.") + + body['headline'][key.value] = value + + if content: + body['content'] = {} + + for key, value in content.items(): + if not isinstance(key, Locale): + raise TypeError("The content's keys must be an instance of Locale.") + + body['content'][key.value] = value + elif not headline: + raise ValueError('headline or content must be specified.') + + await self.__request('PATCH', '/projects/@me', body=body) + + async def post_commands(self, commands: list[dict]) -> None: """ Tries to update the application commands list in your Discord bot's Top.gg page. diff --git a/topgg/project.py b/topgg/project.py index 6670125..e391561 100644 --- a/topgg/project.py +++ b/topgg/project.py @@ -116,3 +116,26 @@ def __int__(self) -> int: def __eq__(self, other: object) -> bool: return isinstance(other, __class__) and self.id == other.id + + +class Locale(Enum): + """A project's 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/ratelimiter.py b/topgg/ratelimiter.py index 8efdb0e..47e6e95 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -3,7 +3,7 @@ # SPDX-FileCopyrightText: 2024-2026 null8626 & Top.gg -from dataclasses import dataclass +from dataclasses import dataclass, field from collections import deque from time import time import asyncio @@ -19,7 +19,7 @@ class Ratelimiter: _max_calls: int _period: float = 1.0 - _calls: deque[float] = deque() + _calls: deque[float] = field(default_factory=deque) _lock: asyncio.Lock = asyncio.Lock() async def __aenter__(self) -> 'Ratelimiter': From a558668e18d804f73bef2693a68acf8b592e8297 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:41:43 +0700 Subject: [PATCH 03/11] refactor: refactor type imports --- tests/test_client.py | 6 +++++- tests/test_webhooks.py | 4 +++- tests/util.py | 3 ++- topgg/errors.py | 5 ++++- topgg/payload.py | 5 ++++- topgg/ratelimiter.py | 4 ++-- topgg/user.py | 9 +++++---- topgg/webhooks.py | 22 +++++++++++++--------- topgg/widget.py | 6 +++++- 9 files changed, 43 insertions(+), 21 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index b99ec53..0abc87c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,12 +6,16 @@ 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 diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index f1c2c25..47d8b11 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -1,4 +1,3 @@ -from collections.abc import AsyncGenerator, Callable from aiohttp import test_utils, web from typing import TYPE_CHECKING from functools import cache @@ -9,6 +8,9 @@ import pytest import hmac +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Callable + import topgg from util import _test_attributes, CURRENT_DIR diff --git a/tests/util.py b/tests/util.py index 11be104..ce9d8d5 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__)) diff --git a/topgg/errors.py b/topgg/errors.py index 2e0bf51..d84ec30 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -3,7 +3,10 @@ # SPDX-FileCopyrightText: 2024-2026 null8626 & Top.gg from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any class Error(Exception): diff --git a/topgg/payload.py b/topgg/payload.py index 79d5a60..e1c7deb 100644 --- a/topgg/payload.py +++ b/topgg/payload.py @@ -1,9 +1,12 @@ # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: 2026 null8626 & Top.gg -from datetime import datetime +from typing import TYPE_CHECKING from enum import Enum +if TYPE_CHECKING: + from datetime import datetime + from .project import PartialProject from .user import User from .util import parse_timestamp diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index 47e6e95..380bd52 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -4,12 +4,12 @@ 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 diff --git a/topgg/user.py b/topgg/user.py index 34d5d1e..eb4dd31 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 datetime import datetime -from .util import parse_timestamp + from .client import Client class UserSource(Enum): diff --git a/topgg/webhooks.py b/topgg/webhooks.py index 81a44ee..0551cf6 100644 --- a/topgg/webhooks.py +++ b/topgg/webhooks.py @@ -2,26 +2,30 @@ # SPDX-FileCopyrightText: 2021-2024 Assanali Mukhanov & Top.gg # SPDX-FileCopyrightText: 2024-2026 null8626 & Top.gg -from collections.abc import Awaitable, Callable 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 -from .payload import ( - IntegrationCreatePayload, - IntegrationDeletePayload, - TestPayload, - PayloadType, - VoteCreatePayload, -) from .client import API_VERSION +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + from typing import Any, TypeAlias + + from .payload import ( + IntegrationCreatePayload, + IntegrationDeletePayload, + TestPayload, + PayloadType, + VoteCreatePayload, + ) + IntegrationCreateListener: TypeAlias = Callable[ [IntegrationCreatePayload, str], Awaitable[web.Response] diff --git a/topgg/widget.py b/topgg/widget.py index 25465e1..136ed06 100644 --- a/topgg/widget.py +++ b/topgg/widget.py @@ -1,9 +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 +if TYPE_CHECKING: + from .project import Platform, ProjectType + class Widget: """A Top.gg widget URL generator.""" From fbe8e1289d1a89a2c945e03150c2116b4edd37a3 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:44:15 +0700 Subject: [PATCH 04/11] feat: add post_announcement --- ruff.toml | 5 ++- tests/mocks/post_announcement.json | 5 +++ tests/test_client.py | 59 +++++++++++++++++++++++------- tests/test_webhooks.py | 9 ++--- topgg/__init__.py | 10 ++++- topgg/client.py | 55 +++++++++++++++++++++++----- topgg/project.py | 29 +++++++++++++++ topgg/ratelimiter.py | 24 ++++++++---- topgg/webhooks.py | 25 ++++++------- topgg/widget.py | 3 +- 10 files changed, 172 insertions(+), 52 deletions(-) create mode 100644 tests/mocks/post_announcement.json 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/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/test_client.py b/tests/test_client.py index 0abc87c..a0362f1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -143,6 +143,13 @@ 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) @@ -162,12 +169,36 @@ async def test_Client_post_commands_works( 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="^The specified commands is not a list of dicts in the form of Discord API's raw JSON format.$", + match=r'^The specified title and content must be a string\.$', ): - await client.post_commands(None) + await client.post_announcement(None, 2) + + with pytest.raises( + ValueError, + match=r'^The specified title and\/or 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 @@ -175,6 +206,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) @@ -206,18 +243,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) @@ -230,9 +267,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 47d8b11..95a2ad4 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from aiohttp import test_utils, web from typing import TYPE_CHECKING from functools import cache @@ -9,7 +10,7 @@ import hmac if TYPE_CHECKING: - from collections.abc import AsyncGenerator, Callable + from collections.abc import AsyncGenerator import topgg @@ -64,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) @@ -87,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/topgg/__init__.py b/topgg/__init__.py index f291777..0e5e58a 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -19,7 +19,14 @@ VoteCreatePayload, ) from .user import PaginatedVotes, PartialVote, User, UserSource, Vote -from .project import Locale, PartialProject, Platform, Project, ProjectType +from .project import ( + Announcement, + Locale, + PartialProject, + Platform, + Project, + ProjectType, +) from .client import API_VERSION, BASE_URL, Client from .errors import Error, Ratelimited, RequestError from .ratelimiter import Ratelimiter @@ -36,6 +43,7 @@ __copyright__ = 'Copyright (c) 2024-2026 null8626 & Top.gg' __version__ = VERSION __all__ = ( + 'Announcement', 'API_VERSION', 'BASE_URL', 'Client', diff --git a/topgg/client.py b/topgg/client.py index 114e87d..da0b85c 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -17,7 +17,7 @@ from .user import PaginatedVotes, PartialVote, UserSource from .errors import Error, Ratelimited, RequestError from .ratelimiter import Ratelimiter -from .project import Locale, Project +from .project import Announcement, Locale, Project from .version import VERSION @@ -66,10 +66,11 @@ 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_votes_number': Ratelimiter(99), + 'projects_@me_votes': Ratelimiter(99), } self.__ratelimiters = endpoint_ratelimits @@ -127,8 +128,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 @@ -168,12 +171,12 @@ async def edit_self( self, *, headline: dict[Locale, str] = {}, content: dict[Locale, str] = {} ) -> None: """ - Updates your project's information. + Tries to update your project's information. :param headline: A locale mapping of your project's headline. - :type headline: list[:py:class:`dict`] + :type headline: dict[:class:`.Locale`, :py:class:`str`] :param content: A locale mapping of your project's page content. - :type content: list[:py:class:`dict`] + :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`. @@ -228,6 +231,40 @@ async def post_commands(self, commands: list[dict]) -> None: await self.__request('POST', '/projects/@me/commands', body=commands) + 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/or 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/or 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 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/project.py b/topgg/project.py index e391561..ac8fa7c 100644 --- a/topgg/project.py +++ b/topgg/project.py @@ -1,8 +1,14 @@ # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: 2026 null8626 & Top.gg +from typing import TYPE_CHECKING from enum import Enum +if TYPE_CHECKING: + from datetime import datetime + +from .util import parse_timestamp + class Platform(Enum): """A project's platform.""" @@ -139,3 +145,26 @@ class Locale(Enum): UKRAINIAN = 'uk' VIETNAMESE = 'vi' CHINESE = 'zh' + + +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}>' diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index 380bd52..f50d200 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -2,7 +2,6 @@ # 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 @@ -13,6 +12,9 @@ from types import TracebackType +MAXIMUM_DELAY_THRESHOLD = 5 * 60 + + @dataclass(repr=False, slots=True) class Ratelimiter: """Handles ratelimits for a specific endpoint.""" @@ -21,16 +23,21 @@ class Ratelimiter: _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 + 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 @@ -43,10 +50,13 @@ async def __aexit__( """Stores the previous request's timestamp.""" async with self._lock: - self._calls.append(time()) + 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/webhooks.py b/topgg/webhooks.py index 0551cf6..b10df8d 100644 --- a/topgg/webhooks.py +++ b/topgg/webhooks.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: 2021-2024 Assanali Mukhanov & Top.gg # SPDX-FileCopyrightText: 2024-2026 null8626 & Top.gg +from collections.abc import Awaitable, Callable from asyncio import wait_for, TimeoutError from inspect import iscoroutinefunction from aiohttp import test_utils, web @@ -12,19 +13,17 @@ import hmac import json -from .client import API_VERSION - if TYPE_CHECKING: - from collections.abc import Awaitable, Callable from typing import Any, TypeAlias - from .payload import ( - IntegrationCreatePayload, - IntegrationDeletePayload, - TestPayload, - PayloadType, - VoteCreatePayload, - ) +from .client import API_VERSION +from .payload import ( + IntegrationCreatePayload, + IntegrationDeletePayload, + TestPayload, + PayloadType, + VoteCreatePayload, +) IntegrationCreateListener: TypeAlias = Callable[ @@ -118,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): @@ -164,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.') diff --git a/topgg/widget.py b/topgg/widget.py index 136ed06..e3b8964 100644 --- a/topgg/widget.py +++ b/topgg/widget.py @@ -4,9 +4,10 @@ from typing import TYPE_CHECKING from .client import BASE_URL +from .project import ProjectType if TYPE_CHECKING: - from .project import Platform, ProjectType + from .project import Platform class Widget: From 7abc6bfe16b6dfab6d75b50c580a34f1f6ffa03d Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:21:29 +0700 Subject: [PATCH 05/11] feat: add post_metrics --- tests/test_client.py | 76 +++++++++++++++++++++++++++++----- topgg/__init__.py | 2 + topgg/client.py | 98 +++++++++++++++++++++++++++++++------------- topgg/errors.py | 4 +- topgg/project.py | 49 +++++++++++++++++++++- topgg/util.py | 7 ++++ topgg/webhooks.py | 2 +- 7 files changed, 195 insertions(+), 43 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index a0362f1..b5138bf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -138,6 +138,37 @@ async def test_Client_edit_self_works( 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, @@ -171,34 +202,57 @@ async def test_Client_post_commands_works( @pytest.mark.asyncio -async def test_Client_post_announcement_works( +async def test_Client_post_metrics_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\.$', + match=r'^The specified metrics has an invalid type\.$', ): - await client.post_announcement(None, 2) + 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 title and\/or content length must be within the accepted ranges\.$', + match=r'^The specified batch of metrics must not be empty\.$', ): - await client.post_announcement('test', 'test') + await client.post_metrics({}) - with RequestMock(200, 'OK', response='mocks/post_announcement.json') as request: + 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) - announcement = await client.post_announcement( - 'Version 2.0 Released!', - 'We just released version 2.0 with a bunch of new features and improvements.', + 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)) - _test_attributes(announcement) + metrics = topgg.Metrics.roblox_game(1) - request.assert_called_once() + await client.post_metrics(metrics) + await client.post_metrics({datetime.now(): metrics}) + + assert request.call_count == 6 @pytest.mark.asyncio diff --git a/topgg/__init__.py b/topgg/__init__.py index 0e5e58a..b4bca02 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -22,6 +22,7 @@ from .project import ( Announcement, Locale, + Metrics, PartialProject, Platform, Project, @@ -54,6 +55,7 @@ 'IntegrationDeletePayload', 'Listener', 'Locale', + 'Metrics', 'PaginatedVotes', 'PartialProject', 'PartialVote', diff --git a/topgg/client.py b/topgg/client.py index da0b85c..8e36216 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -17,7 +17,7 @@ from .user import PaginatedVotes, PartialVote, UserSource from .errors import Error, Ratelimited, RequestError from .ratelimiter import Ratelimiter -from .project import Announcement, Locale, Project +from .project import Announcement, Locale, Metrics, Project from .version import VERSION @@ -69,6 +69,8 @@ def __init__(self, token: str, *, session: ClientSession | None = None): '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), } @@ -190,25 +192,59 @@ async def edit_self( if headline: body['headline'] = {} - for key, value in headline.items(): - if not isinstance(key, Locale): + for locale, value in headline.items(): + if not isinstance(locale, Locale): raise TypeError("The headline's keys must be an instance of Locale.") - body['headline'][key.value] = value + body['headline'][locale.value] = value if content: body['content'] = {} - for key, value in content.items(): - if not isinstance(key, Locale): + for locale, value in content.items(): + if not isinstance(locale, Locale): raise TypeError("The content's keys must be an instance of Locale.") - body['content'][key.value] = value + body['content'][locale.value] = value 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. @@ -231,39 +267,45 @@ async def post_commands(self, commands: list[dict]) -> None: await self.__request('POST', '/projects/@me/commands', body=commands) - async def post_announcement(self, title: str, content: str) -> Announcement: + async def post_metrics(self, metrics: Metrics | dict[datetime, Metrics]) -> None: """ - 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. + 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 title: The announcement's title. - :type title: :py:class:`str` - :param content: The announcement's content. - :type content: :py:class:`str` + :param metrics: A single or batch of metrics. + :type metrics: :class:`.Metrics` | dict[:py:class:`~datetime.datetime`, :class:`.Metrics`] - :exception TypeError: The specified title and content is not a :py:class:`str`. - :exception ValueError: The specified title and/or content length is not within the accepted ranges. + :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. - - :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/or content length must be within the accepted ranges.' + 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() ) - - return Announcement( + ): + 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/announcements', - body={'title': title[100:], 'content': content[2000:]}, + '/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: """ diff --git a/topgg/errors.py b/topgg/errors.py index d84ec30..e1a0362 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -15,7 +15,7 @@ class Error(Exception): __slots__: tuple[str, ...] = () -@dataclass(repr=False, slots=True) +@dataclass(frozen=True, repr=False, slots=True) class RequestError(Error): """Thrown upon HTTP request failure. Extends :class:`~.errors.Error`.""" @@ -32,7 +32,7 @@ def __repr__(self) -> str: return f'<{__class__.__name__} data={self.data!r} status={self.status}>' -@dataclass(repr=False, slots=True) +@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`.""" diff --git a/topgg/project.py b/topgg/project.py index ac8fa7c..e302419 100644 --- a/topgg/project.py +++ b/topgg/project.py @@ -1,13 +1,14 @@ # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: 2026 null8626 & Top.gg +from dataclasses import dataclass from typing import TYPE_CHECKING from enum import Enum if TYPE_CHECKING: from datetime import datetime -from .util import parse_timestamp +from .util import parse_timestamp, safe_dict class Platform(Enum): @@ -168,3 +169,49 @@ def __init__(self, json: dict): 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/util.py b/topgg/util.py index 5c3f55f..80d01c1 100644 --- a/topgg/util.py +++ b/topgg/util.py @@ -3,6 +3,7 @@ from datetime import datetime from sys import version_info +from typing import Any if version_info.major == 3 and version_info.minor <= 10: # pragma: nocover @@ -21,3 +22,9 @@ 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} diff --git a/topgg/webhooks.py b/topgg/webhooks.py index b10df8d..4f12069 100644 --- a/topgg/webhooks.py +++ b/topgg/webhooks.py @@ -174,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. From a107daca8a2f58d1a030e28ab7b11f6effd96f10 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:35:26 +0700 Subject: [PATCH 06/11] refactor: add insert_locale_mapping and move Locale to its own file --- tests/test_client.py | 6 +++--- topgg/__init__.py | 4 ++-- topgg/client.py | 20 +++++--------------- topgg/locale.py | 24 ++++++++++++++++++++++++ topgg/project.py | 23 ----------------------- topgg/util.py | 19 ++++++++++++++++++- 6 files changed, 52 insertions(+), 44 deletions(-) create mode 100644 topgg/locale.py diff --git a/tests/test_client.py b/tests/test_client.py index b5138bf..1f13b5a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -113,17 +113,17 @@ async def test_Client_edit_self_works( ) -> None: if not TYPE_CHECKING: with pytest.raises( - TypeError, match=r'^The headline\'s keys must be an instance of Locale\.$' + 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 headline\'s keys must be an instance of Locale\.$' + 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 content\'s keys must be an instance of Locale\.$' + TypeError, match=r'^The locale mapping\'s keys must be an instance of Locale\.$' ): await client.edit_self(content={'en': 'test'}) diff --git a/topgg/__init__.py b/topgg/__init__.py index b4bca02..97a8042 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -21,17 +21,17 @@ from .user import PaginatedVotes, PartialVote, User, UserSource, Vote from .project import ( Announcement, - Locale, Metrics, PartialProject, Platform, Project, ProjectType, ) -from .client import API_VERSION, BASE_URL, Client 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 diff --git a/topgg/client.py b/topgg/client.py index 8e36216..2f9f49c 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -16,9 +16,11 @@ 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 Announcement, Locale, Metrics, Project from .version import VERSION +from .locale import Locale API_VERSION = 'v1' @@ -190,22 +192,10 @@ async def edit_self( body = {} if headline: - body['headline'] = {} - - for locale, value in headline.items(): - if not isinstance(locale, Locale): - raise TypeError("The headline's keys must be an instance of Locale.") - - body['headline'][locale.value] = value + insert_locale_mapping('headline', headline, body) if content: - body['content'] = {} - - for locale, value in content.items(): - if not isinstance(locale, Locale): - raise TypeError("The content's keys must be an instance of Locale.") - - body['content'][locale.value] = value + insert_locale_mapping('content', content, body) elif not headline: raise ValueError('headline or content must be specified.') 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 e302419..6f0e5e8 100644 --- a/topgg/project.py +++ b/topgg/project.py @@ -125,29 +125,6 @@ def __eq__(self, other: object) -> bool: return isinstance(other, __class__) and self.id == other.id -class Locale(Enum): - """A project's 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' - - class Announcement: """A project's announcement.""" diff --git a/topgg/util.py b/topgg/util.py index 80d01c1..c9a820a 100644 --- a/topgg/util.py +++ b/topgg/util.py @@ -1,9 +1,14 @@ # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: 2026 null8626 & Top.gg +from typing import TYPE_CHECKING from datetime import datetime from sys import version_info -from typing import Any + +if TYPE_CHECKING: + from typing import Any + +from .locale import Locale if version_info.major == 3 and version_info.minor <= 10: # pragma: nocover @@ -28,3 +33,15 @@ 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 From aebd8dbc9c64997163c2134c99523c32431860c7 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:52:06 +0700 Subject: [PATCH 07/11] meta: update raw API documentation URL --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From fed629ab4c571b055d5efc3276eb5b58d33b2010 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:22:59 +0700 Subject: [PATCH 08/11] doc: fix created_at's datetime hyperlink not showing properly --- topgg/project.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/topgg/project.py b/topgg/project.py index 6f0e5e8..2e30487 100644 --- a/topgg/project.py +++ b/topgg/project.py @@ -2,12 +2,9 @@ # SPDX-FileCopyrightText: 2026 null8626 & Top.gg from dataclasses import dataclass -from typing import TYPE_CHECKING +from datetime import datetime from enum import Enum -if TYPE_CHECKING: - from datetime import datetime - from .util import parse_timestamp, safe_dict From cc1e528464b0cd655d2242eff100d9d448b74a0d Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:29:04 +0700 Subject: [PATCH 09/11] feat: add new platform and project type --- topgg/project.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/topgg/project.py b/topgg/project.py index 2e30487..250f046 100644 --- a/topgg/project.py +++ b/topgg/project.py @@ -12,6 +12,7 @@ class Platform(Enum): """A project's platform.""" DISCORD = 'discord' + ROBLOX = 'roblox' class ProjectType(Enum): @@ -19,6 +20,7 @@ class ProjectType(Enum): BOT = 'bot' SERVER = 'server' + GAME = 'game' class PartialProject: From 2ec5bdb9984341d84ea6de7a0677a47c9624309b Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:39:40 +0700 Subject: [PATCH 10/11] doc: fix type hyperlinks not working --- topgg/payload.py | 5 +---- topgg/user.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/topgg/payload.py b/topgg/payload.py index e1c7deb..79d5a60 100644 --- a/topgg/payload.py +++ b/topgg/payload.py @@ -1,12 +1,9 @@ # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: 2026 null8626 & Top.gg -from typing import TYPE_CHECKING +from datetime import datetime from enum import Enum -if TYPE_CHECKING: - from datetime import datetime - from .project import PartialProject from .user import User from .util import parse_timestamp diff --git a/topgg/user.py b/topgg/user.py index eb4dd31..fe02df7 100644 --- a/topgg/user.py +++ b/topgg/user.py @@ -2,13 +2,13 @@ # SPDX-FileCopyrightText: 2026 null8626 & Top.gg from typing import TYPE_CHECKING +from datetime import datetime from enum import Enum from .util import parse_timestamp if TYPE_CHECKING: from collections.abc import Iterator - from datetime import datetime from .client import Client From f87854d4765e4fb114c8b124ac0a6ec85bceb7e4 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:44:51 +0700 Subject: [PATCH 11/11] fix: fix explicit typing errors --- tests/mocks/404.json | 8 +-- tests/mocks/get_self.json | 30 +++++----- tests/mocks/get_vote.json | 8 +-- tests/mocks/get_votes.json | 64 ++++++++++----------- tests/mocks/integration_create_payload.json | 36 ++++++------ tests/mocks/integration_delete_payload.json | 10 ++-- tests/mocks/test_payload.json | 32 +++++------ tests/mocks/vote_create_payload.json | 40 ++++++------- tests/test_client.py | 2 +- tests/test_webhooks.py | 2 +- tests/util.py | 2 +- topgg/client.py | 4 +- topgg/errors.py | 2 +- topgg/user.py | 2 +- topgg/util.py | 2 +- topgg/webhooks.py | 12 ++-- topgg/widget.py | 8 +-- 17 files changed, 132 insertions(+), 132 deletions(-) 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/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 1f13b5a..a245b2a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -28,7 +28,7 @@ @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()])) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 95a2ad4..f805442 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -24,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) diff --git a/tests/util.py b/tests/util.py index ce9d8d5..1e9da92 100644 --- a/tests/util.py +++ b/tests/util.py @@ -159,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/client.py b/topgg/client.py index 2f9f49c..edcb1c8 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -84,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.') diff --git a/topgg/errors.py b/topgg/errors.py index e1a0362..088e3e5 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -19,7 +19,7 @@ class Error(Exception): class RequestError(Error): """Thrown upon HTTP request failure. Extends :class:`~.errors.Error`.""" - data: Any + data: 'Any' """The JSON error data returned from the API.""" status: int | None diff --git a/topgg/user.py b/topgg/user.py index fe02df7..927a832 100644 --- a/topgg/user.py +++ b/topgg/user.py @@ -107,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 c9a820a..f0fc0e5 100644 --- a/topgg/util.py +++ b/topgg/util.py @@ -29,7 +29,7 @@ def parse_timestamp(timestamp: str) -> datetime: return datetime.fromisoformat(timestamp.replace('Z', '+00:00')) -def safe_dict(**kwargs: Any) -> dict: +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} diff --git a/topgg/webhooks.py b/topgg/webhooks.py index 4f12069..c95ef75 100644 --- a/topgg/webhooks.py +++ b/topgg/webhooks.py @@ -26,25 +26,25 @@ ) -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 @@ -216,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 e3b8964..73a3c90 100644 --- a/topgg/widget.py +++ b/topgg/widget.py @@ -16,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. @@ -41,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. @@ -66,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. @@ -91,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.