From 8957a91a9f63166145be7b7d87ec9d41aa007aec Mon Sep 17 00:00:00 2001 From: Vivi Date: Sun, 3 May 2026 16:19:12 +0200 Subject: [PATCH 1/9] Slice direction in post_announcement title[100:] -> title[:100] and content[2000:] -> content[:2000]. The old code was discarding the first N characters instead of keeping them. --- topgg/client.py | 84 +++++++++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index edcb1c8..9da7a94 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -63,7 +63,7 @@ def __init__(self, token: str, *, session: ClientSession | None = None): self.__own_session = session is None self.__session = session or ClientSession( - timeout=ClientTimeout(total=MAXIMUM_DELAY_THRESHOLD * 1000.0) + timeout=ClientTimeout(total=MAXIMUM_DELAY_THRESHOLD) ) self.__token = token @@ -113,49 +113,58 @@ async def __request( if body is not None: kwargs['data'] = json.dumps(body) - status = None - retry_after = 0.0 - output = None - - async with ratelimiter: - try: - async with self.__session.request( - method, - BASE_URL + path, - headers={ - 'Authorization': f'Bearer {self.__token}', - 'Content-Type': 'application/json', - 'User-Agent': f'topggpy (https://github.com/top-gg-community/python-sdk {VERSION}) Python/', - }, - **kwargs, - ) as resp: - status = resp.status - - try: + max_retries = 3 + retries = 0 + + while True: + status = None + retry_after = 0.0 + output = None + + async with ratelimiter: + try: + async with self.__session.request( + method, + BASE_URL + path, + headers={ + 'Authorization': f'Bearer {self.__token}', + 'Content-Type': 'application/json', + 'User-Agent': f'topggpy (https://github.com/top-gg-community/python-sdk {VERSION}) Python/', + }, + **kwargs, + ) as resp: + status = resp.status + try: - output = await resp.json() - except: + try: + output = await resp.json() + except: + pass + + retry_after = float(resp.headers.get('Retry-After', 0)) + except (ValueError, json.decoder.JSONDecodeError): # pragma: nocover pass - retry_after = float(resp.headers.get('Retry-After', 0)) - except (ValueError, json.decoder.JSONDecodeError): # pragma: nocover - pass + resp.raise_for_status() - resp.raise_for_status() + return output + except ClientResponseError: + if status == 429 and retry_after is not None: + if retry_after > MAXIMUM_DELAY_THRESHOLD: + self.__current_ratelimits[ratelimiter_key] = time() + retry_after - return output - except ClientResponseError: - if status == 429 and retry_after is not None: - if retry_after > MAXIMUM_DELAY_THRESHOLD: - self.__current_ratelimits[ratelimiter_key] = time() + retry_after + raise Ratelimited(retry_after) from None + else: # pragma: nocover + retries += 1 - raise Ratelimited(retry_after) from None - else: # pragma: nocover - await sleep(retry_after) + if retries >= max_retries: + raise Ratelimited(retry_after) from None - return await self.__request(method, path, params=params, body=body) + await sleep(retry_after) - raise RequestError(output and output.get('detail', output), status) from None + continue + + raise RequestError(output and output.get('detail', output), status) from None async def get_self(self) -> Project: """ @@ -231,7 +240,7 @@ async def post_announcement(self, title: str, content: str) -> Announcement: await self.__request( 'POST', '/projects/@me/announcements', - body={'title': title[100:], 'content': content[2000:]}, + body={'title': title[:100], 'content': content[:2000]}, ) ) @@ -367,3 +376,4 @@ async def __aenter__(self) -> 'Client': async def __aexit__(self, *_, **__) -> None: await self.close() + From 90f27ac6adddfccbe6b862da11656b8ac3a6cd28 Mon Sep 17 00:00:00 2001 From: Vivi Date: Sun, 3 May 2026 16:22:37 +0200 Subject: [PATCH 2/9] Change lock initialization to use default_factory --- topgg/ratelimiter.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index f50d200..8dbe9c4 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -22,7 +22,7 @@ class Ratelimiter: _max_calls: int _period: float = 1.0 _calls: deque[float] = field(default_factory=deque) - _lock: asyncio.Lock = asyncio.Lock() + _lock: asyncio.Lock = field(default_factory=asyncio.Lock) _cancelled_delay: bool = field(default=False, init=False) async def __aenter__(self) -> 'Ratelimiter': @@ -62,4 +62,6 @@ async def __aexit__( def _timespan(self) -> float: """The timespan between the first call and last call.""" + if len(self._calls) < 2: + return 0.0 return self._calls[-1] - self._calls[0] From 8023f42975d53f6cd24b42db00ee38f44d23d2f8 Mon Sep 17 00:00:00 2001 From: Vivi Date: Sun, 3 May 2026 16:23:28 +0200 Subject: [PATCH 3/9] Fix conditional checks for server and member counts --- topgg/project.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/topgg/project.py b/topgg/project.py index 250f046..1fd2b62 100644 --- a/topgg/project.py +++ b/topgg/project.py @@ -159,7 +159,7 @@ def discord_bot( ) -> 'Metrics': """Creates a new Discord bot metrics.""" - if not (server_count or shard_count) or not ( + if server_count is None and shard_count is None or not ( isinstance(server_count, int) or isinstance(shard_count, int) ): raise TypeError( @@ -174,7 +174,7 @@ def discord_server( ) -> 'Metrics': """Creates a new Discord server metrics.""" - if not (member_count or online_count) or not ( + if member_count is None and online_count is None or not ( isinstance(member_count, int) or isinstance(online_count, int) ): raise TypeError( From 20b530df2e734d69db664c42fd427ccdd9705564 Mon Sep 17 00:00:00 2001 From: Vivi Date: Sun, 3 May 2026 16:24:08 +0200 Subject: [PATCH 4/9] Add user attribute to webhook payload Added user attribute to webhook payload class. --- topgg/payload.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/topgg/payload.py b/topgg/payload.py index 79d5a60..22c3410 100644 --- a/topgg/payload.py +++ b/topgg/payload.py @@ -68,11 +68,11 @@ class TestPayload: __slots__: tuple[str, ...] = ('project', 'user') - """The unique identifier for this connection.""" project: PartialProject - """The project that the test refers to.""" + user: User + """The user who triggered this event.""" def __init__(self, json: dict): self.project = PartialProject(json['project']) From 4ad0a07176837828cc511019fb4198d3cc977fdb Mon Sep 17 00:00:00 2001 From: Vivi Date: Sun, 3 May 2026 16:53:58 +0200 Subject: [PATCH 5/9] Update user.py --- topgg/user.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/topgg/user.py b/topgg/user.py index 927a832..2a4a9b3 100644 --- a/topgg/user.py +++ b/topgg/user.py @@ -87,10 +87,17 @@ def __init__(self, client: 'Client', json: dict): self.__votes = [Vote(vote) for vote in json['data']] self.__cursor = json['cursor'] + @property + def has_next(self) -> bool: + """Whether there is a next page of votes.""" + + return bool(self.__cursor) + async def next(self) -> 'PaginatedVotes': """ Tries to advance to the next page. + :exception ValueError: There is no next page. :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. @@ -99,6 +106,9 @@ async def next(self) -> 'PaginatedVotes': :rtype: :class:`.PaginatedVotes` """ + if not self.has_next: + raise ValueError('There is no next page.') + return await self.__client._get_votes(cursor=self.__cursor) def __repr__(self) -> str: @@ -142,3 +152,4 @@ def __int__(self) -> int: def __eq__(self, other: object) -> bool: return isinstance(other, __class__) and self.id == other.id + From f3cb300662cba54189d4e78e99506750a80276aa Mon Sep 17 00:00:00 2001 From: Vivi Date: Sun, 3 May 2026 16:54:41 +0200 Subject: [PATCH 6/9] Refactor widget methods to include platform parameter --- topgg/widget.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/topgg/widget.py b/topgg/widget.py index 73a3c90..f624251 100644 --- a/topgg/widget.py +++ b/topgg/widget.py @@ -4,10 +4,7 @@ from typing import TYPE_CHECKING from .client import BASE_URL -from .project import ProjectType - -if TYPE_CHECKING: - from .project import Platform +from .project import Platform, ProjectType class Widget: @@ -33,7 +30,7 @@ def large(platform: 'Platform', project_type: ProjectType, id: int) -> str: :rtype: :py:class:`.str` """ - if not isinstance(project_type, ProjectType) or not isinstance(id, int): + if not isinstance(platform, Platform) or not isinstance(project_type, ProjectType) or not isinstance(id, int): raise TypeError( "The specified platform, project type, and/or project ID's type is invalid." ) @@ -41,7 +38,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. @@ -58,7 +55,7 @@ def votes(platform: 'Platform', project_type: ProjectType, id: int) -> str: :rtype: :py:class:`.str` """ - if not isinstance(project_type, ProjectType) or not isinstance(id, int): + if not isinstance(platform, Platform) or not isinstance(project_type, ProjectType) or not isinstance(id, int): raise TypeError( "The specified platform, project type, and/or project ID's type is invalid." ) @@ -66,7 +63,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. @@ -83,7 +80,7 @@ def owner(platform: 'Platform', project_type: ProjectType, id: int) -> str: :rtype: :py:class:`.str` """ - if not isinstance(project_type, ProjectType) or not isinstance(id, int): + if not isinstance(platform, Platform) or not isinstance(project_type, ProjectType) or not isinstance(id, int): raise TypeError( "The specified platform, project type, and/or project ID's type is invalid." ) @@ -91,7 +88,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. @@ -108,7 +105,7 @@ def social(platform: 'Platform', project_type: ProjectType, id: int) -> str: :rtype: :py:class:`.str` """ - if not isinstance(project_type, ProjectType) or not isinstance(id, int): + if not isinstance(platform, Platform) or not isinstance(project_type, ProjectType) or not isinstance(id, int): raise TypeError( "The specified platform, project type, and/or project ID's type is invalid." ) From 8b7af4b42ad897e6b0b121482c62d59d8a8644dd Mon Sep 17 00:00:00 2001 From: Vivi Date: Sun, 3 May 2026 16:55:43 +0200 Subject: [PATCH 7/9] Remove __slots__ from Locale class Removed __slots__ declaration from Locale class. --- topgg/locale.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/topgg/locale.py b/topgg/locale.py index e187bed..bf51537 100644 --- a/topgg/locale.py +++ b/topgg/locale.py @@ -4,8 +4,6 @@ class Locale(Enum): """A supported locale.""" - __slots__: tuple[str, ...] = () - ENGLISH = 'en' GERMAN = 'de' FRENCH = 'fr' From a789ce3f204cf8d6ba0577005fb666921e13c985 Mon Sep 17 00:00:00 2001 From: Vivi Date: Sun, 3 May 2026 16:57:02 +0200 Subject: [PATCH 8/9] Update what's new section for v2.0.0 and v2.0.0a Added details about the new features and changes in version 2.0.0 and 2.0.0a of the SDK for Top.gg API. --- docs/whats_new.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 8fc1d0e..5a8a253 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -8,8 +8,25 @@ What's New This page keeps a detailed human friendly rendering of what's new and changed in specific versions. +v2.0.0 +====== + +Complete rewrite of the SDK for Top.gg API v1. + +* New :class:`.Client` class replaces the old ``DBLClient`` +* New :class:`.Webhooks` class replaces the old ``WebhookManager`` +* New webhook payload classes: :class:`.VoteCreatePayload`, :class:`.TestPayload`, :class:`.IntegrationCreatePayload`, :class:`.IntegrationDeletePayload` +* New :class:`.Project`, :class:`.PartialProject`, :class:`.Announcement`, :class:`.Metrics` data models +* New :class:`.PaginatedVotes` for cursor-based paginated vote retrieval via :meth:`.Client.get_votes` +* New :class:`.Widget` class for generating widget URLs across all platforms and project types +* New :class:`.Platform`, :class:`.ProjectType`, :class:`.PayloadType`, :class:`.UserSource`, :class:`.Locale` enumerations +* No longer depends on any Discord API wrapper +* Per-endpoint rate limiting with :class:`.Ratelimiter` +* Async context manager support on :class:`.Client` + v2.0.0a ======= + * :obj:`~.DBLClient` now doesn't take in ``discord.Client`` instance * Introduced new `autopost `__ and `data injection `__ API * `Webhook `__ API breaking changes From ba63d05c7d41f61b790d04fb6a01d2d60e0b2dc6 Mon Sep 17 00:00:00 2001 From: Vivi Date: Sun, 3 May 2026 16:58:11 +0200 Subject: [PATCH 9/9] Update cursor value in get_votes mock data --- tests/mocks/get_votes.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/mocks/get_votes.json b/tests/mocks/get_votes.json index a8c84f6..7b50954 100644 --- a/tests/mocks/get_votes.json +++ b/tests/mocks/get_votes.json @@ -1,5 +1,5 @@ { - "cursor": "", + "cursor": "next_page_cursor_token", "data": [ { "user_id": "800506814562787328", @@ -30,4 +30,4 @@ "expires_at": "2026-02-26T10:35:36.978392Z" } ] -} \ No newline at end of file +}