Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/whats_new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <api/autopost.html>`__ and `data injection <api/data.html>`__ API
* `Webhook <api/webhook.html>`__ API breaking changes
Expand Down
4 changes: 2 additions & 2 deletions tests/mocks/get_votes.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"cursor": "",
"cursor": "next_page_cursor_token",
"data": [
{
"user_id": "800506814562787328",
Expand Down Expand Up @@ -30,4 +30,4 @@
"expires_at": "2026-02-26T10:35:36.978392Z"
}
]
}
}
84 changes: 47 additions & 37 deletions topgg/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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]},
)
)

Expand Down Expand Up @@ -367,3 +376,4 @@ async def __aenter__(self) -> 'Client':

async def __aexit__(self, *_, **__) -> None:
await self.close()

2 changes: 0 additions & 2 deletions topgg/locale.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
class Locale(Enum):
"""A supported locale."""

__slots__: tuple[str, ...] = ()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why delete this?


ENGLISH = 'en'
GERMAN = 'de'
FRENCH = 'fr'
Expand Down
4 changes: 2 additions & 2 deletions topgg/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
4 changes: 2 additions & 2 deletions topgg/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion topgg/ratelimiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -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]
11 changes: 11 additions & 0 deletions topgg/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -142,3 +152,4 @@ def __int__(self) -> int:

def __eq__(self, other: object) -> bool:
return isinstance(other, __class__) and self.id == other.id

19 changes: 8 additions & 11 deletions topgg/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -33,15 +30,15 @@ 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."
)

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.

Expand All @@ -58,15 +55,15 @@ 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."
)

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.

Expand All @@ -83,15 +80,15 @@ 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."
)

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.

Expand All @@ -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."
)
Expand Down