From b22733986ec3ceef30e50f23957bb2b9d356be0d Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 9 Oct 2025 14:34:26 +0700 Subject: [PATCH 01/19] revert: revert most breaking changes --- .gitignore | 14 +- LICENSE | 23 +- MANIFEST.in | 18 +- mypy.ini | 21 -- pyproject.toml | 16 +- pytest.ini | 8 - ruff.toml | 2 +- scripts/format.sh | 2 - topgg/__init__.py | 120 +++++++-- topgg/autopost.py | 306 ++++++++++----------- topgg/client.py | 502 ++++++++++++++++++++++++----------- topgg/data.py | 141 +++++----- topgg/errors.py | 90 +++---- topgg/ratelimiter.py | 15 +- topgg/types.py | 615 +++++++++++++++++++++++++------------------ topgg/version.py | 2 +- topgg/webhook.py | 371 +++++++++++++------------- 17 files changed, 1299 insertions(+), 967 deletions(-) delete mode 100644 mypy.ini delete mode 100644 pytest.ini delete mode 100644 scripts/format.sh diff --git a/.gitignore b/.gitignore index 429ac009..f222ed9d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,6 @@ -dblpy.egg-info/ +**/__pycache__/ topggpy.egg-info/ -topgg/__pycache__/ -build/ +docs/_build/ dist/ -/docs/_build -/docs/_templates -.vscode -/.idea/ -__pycache__ -.coverage -.pytest_cache/ +.ruff_cache/ +.vscode/ \ No newline at end of file diff --git a/LICENSE b/LICENSE index 96aaaf80..3a68f837 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,22 @@ -Copyright 2021 Assanali Mukhanov & Top.gg +The MIT License (MIT) -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Copyright (c) 2021 Assanali Mukhanov & Top.gg +Copyright (c) 2024-2025 null8626 & Top.gg -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index a0e44e5b..eff4806e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,18 +1,10 @@ prune .github -prune .pytest_cache prune .ruff_cache -prune build -prune docs -prune examples -prune scripts -prune tests - -exclude .coverage exclude .gitignore exclude .readthedocs.yml -exclude ISSUE_TEMPLATE.md -exclude mypy.ini -exclude PULL_REQUEST_TEMPLATE.md -exclude pytest.ini exclude ruff.toml -exclude LICENSE \ No newline at end of file +exclude test.py +exclude test_autoposter.py +exclude LICENSE +exclude ISSUE_TEMPLATE.md +exclude PULL_REQUEST_TEMPLATE.md \ No newline at end of file diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 0f2c80ed..00000000 --- a/mypy.ini +++ /dev/null @@ -1,21 +0,0 @@ -# Global options: - -[mypy] -python_version = 3.7 -check_untyped_defs = True -no_implicit_optional = True -ignore_missing_imports = True - -# Allows -allow_untyped_globals = False -allow_redefinition = True - -# Disallows -disallow_incomplete_defs = True -disallow_untyped_defs = True - -# Warns -warn_redundant_casts = True -warn_unreachable = True -warn_unused_configs = True -warn_unused_ignores = True \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 7f800f43..70e81ce8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,36 +3,34 @@ requires = ["setuptools"] [project] name = "topggpy" -version = "3.0.0" -description = "A community-maintained Python API Client for the Top.gg API." +version = "1.5.0" +description = "A simple API wrapper for Top.gg written in Python." readme = "README.md" license = { text = "MIT" } authors = [{ name = "null8626" }, { name = "Top.gg" }] keywords = ["discord", "discord-bot", "topgg"] -dependencies = ["aiohttp>=3.12.15"] +dependencies = ["aiohttp>=3.13.0"] classifiers = [ "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: MIT License", "Intended Audience :: Developers", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Internet", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Utilities" ] -requires-python = ">=3.9" - -[project.optional-dependencies] -dev = ["mock>=5.2.0", "pytest>=8.4.2", "pytest-asyncio>=1.2.0", "pytest-mock>=3.15.0", "pytest-cov>=7.0.0", "ruff>=0.13.0"] +requires-python = ">=3.10" [project.urls] Documentation = "https://topggpy.readthedocs.io/en/latest/" "Raw API Documentation" = "https://docs.top.gg/docs/" Repository = "https://github.com/top-gg-community/python-sdk" -"Support server" = "https://discord.gg/dbl" \ No newline at end of file +"Support server" = "https://discord.gg/EYHTgJX" \ No newline at end of file diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 919cbc45..00000000 --- a/pytest.ini +++ /dev/null @@ -1,8 +0,0 @@ -[pytest] -xfail_strict = true -norecursedirs = docs *.egg-info .git - -filterwarnings = - ignore::DeprecationWarning - -addopts = --cov=topgg \ No newline at end of file diff --git a/ruff.toml b/ruff.toml index 730e2221..6082f28b 100644 --- a/ruff.toml +++ b/ruff.toml @@ -4,7 +4,7 @@ indent-width = 4 docstring-code-format = true docstring-code-line-length = 88 line-ending = "lf" -quote-style = "double" +quote-style = "single" [lint] ignore = ["E402"] \ No newline at end of file diff --git a/scripts/format.sh b/scripts/format.sh deleted file mode 100644 index 4fa2e31d..00000000 --- a/scripts/format.sh +++ /dev/null @@ -1,2 +0,0 @@ -black . -isort . \ No newline at end of file diff --git a/topgg/__init__.py b/topgg/__init__.py index 58c1bce7..d5ed4ca3 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -1,27 +1,105 @@ -# -*- coding: utf-8 -*- - """ -Top.gg Python API Wrapper -~~~~~~~~~~~~~~~~~~~~~~~~~ -A basic wrapper for the Top.gg API. -:copyright: (c) 2021 Assanali Mukhanov & Top.gg -:license: MIT, see LICENSE for more details. +The MIT License (MIT) + +Copyright (c) 2021 Assanali Mukhanov & Top.gg +Copyright (c) 2024-2025 null8626 & Top.gg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. """ +from .autopost import AutoPoster +from .client import DBLClient +from .data import data, DataContainerMixin +from .errors import ( + ClientException, + ClientStateException, + HTTPException, + TopGGException, + Ratelimited, +) +from .types import ( + BotData, + BotsData, + BotStatsData, + BotVoteData, + BriefUserData, + GuildVoteData, + ServerVoteData, + SocialData, + SortBy, + StatsWrapper, + UserData, + VoteDataDict, + WidgetOptions, + WidgetProjectType, + WidgetType, +) from .version import VERSION +from .webhook import ( + BoundWebhookEndpoint, + endpoint, + WebhookEndpoint, + WebhookManager, + WebhookType, +) -__title__ = "topggpy" -__author__ = "Assanali Mukhanov" -__maintainer__ = "Norizon" -__license__ = "MIT" -__version__ = VERSION - -from .autopost import * -from .client import * -from .data import * -from .errors import * -# can't be added to __all__ since they'd clash with automodule -from .types import * -from .types import BotVoteData, GuildVoteData -from .webhook import * +__title__ = 'topggpy' +__author__ = 'null8626 & Top.gg' +__credits__ = ('null8626', 'Top.gg') +__maintainer__ = 'null8626' +__status__ = 'Production' +__license__ = 'MIT' +__copyright__ = 'Copyright (c) 2021 Assanali Mukhanov & Top.gg; Copyright (c) 2024-2025 null8626 & Top.gg' +__version__ = VERSION +__all__ = ( + 'AutoPoster', + 'BotData', + 'BotsData', + 'BotStatsData', + 'BotVoteData', + 'BoundWebhookEndpoint', + 'BriefUserData', + 'ClientException', + 'ClientStateException', + 'data', + 'DataContainerMixin', + 'DBLClient', + 'endpoint', + 'GuildVoteData', + 'HTTPException', + 'Ratelimited', + 'RequestError', + 'ServerVoteData', + 'SocialData', + 'SortBy', + 'StatsWrapper', + 'TopGGException', + 'UserData', + 'VERSION', + 'VoteDataDict', + 'VoteEvent', + 'Voter', + 'WebhookEndpoint', + 'WebhookManager', + 'WebhookType', + 'WidgetOptions', + 'WidgetProjectType', + 'WidgetType', +) diff --git a/topgg/autopost.py b/topgg/autopost.py index c1953f89..9be6ab6b 100644 --- a/topgg/autopost.py +++ b/topgg/autopost.py @@ -1,26 +1,27 @@ -# The MIT License (MIT) - -# Copyright (c) 2021 Norizon - -# Permission is hereby granted, free of charge, to any person obtaining a -# copy of this software and associated documentation files (the "Software"), -# to deal in the Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, distribute, sublicense, -# and/or sell copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. - -__all__ = ["AutoPoster"] +""" +The MIT License (MIT) + +Copyright (c) 2021 Norizon & Top.gg +Copyright (c) 2024-2025 null8626 & Top.gg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" import asyncio import datetime @@ -28,189 +29,192 @@ import traceback import typing as t -from topgg import errors - -from .types import StatsWrapper +from . import errors if t.TYPE_CHECKING: - import asyncio - from .client import DBLClient + from .types import StatsWrapper + CallbackT = t.Callable[..., t.Any] -StatsCallbackT = t.Callable[[], StatsWrapper] +StatsCallbackT = t.Callable[[], 'StatsWrapper'] class AutoPoster: """ - A helper class for autoposting. Takes in a :obj:`~.client.DBLClient` to instantiate. + Automatically update the statistics in your Discord bot's Top.gg page every few minutes. - Note: - You should not instantiate this unless you know what you're doing. - Generally, you'd better use the :meth:`~.client.DBLClient.autopost` method. + Note that you should NOT instantiate this directly unless you know what you're doing. Generally, it's recommended to use the :meth:`.DBLClient.autopost` method instead. - Args: - client (:obj:`~.client.DBLClient`) - An instance of DBLClient. + :param client: The client instance to use. + :type client: :class:`.DBLClient` """ __slots__ = ( - "_error", - "_success", - "_interval", - "_task", - "client", - "_stats", - "_stopping", + '_error', + '_success', + '_interval', + '_task', + 'client', + '_stats', + '_stopping', ) - _success: CallbackT - _stats: CallbackT - _interval: float - _task: t.Optional["asyncio.Task[None]"] - - def __init__(self, client: "DBLClient") -> None: + def __init__(self, client: 'DBLClient') -> None: super().__init__() + self.client = client - self._interval: float = 900 + self._interval = 900 self._error = self._default_error_handler self._refresh_state() def _default_error_handler(self, exception: Exception) -> None: - print("Ignoring exception in auto post loop:", file=sys.stderr) + print('Ignoring exception in auto post loop:', file=sys.stderr) + traceback.print_exception( type(exception), exception, exception.__traceback__, file=sys.stderr ) @t.overload - def on_success(self, callback: None) -> t.Callable[[CallbackT], CallbackT]: - ... + def on_success(self, callback: None) -> t.Callable[[CallbackT], CallbackT]: ... @t.overload - def on_success(self, callback: CallbackT) -> "AutoPoster": - ... + def on_success(self, callback: CallbackT) -> 'AutoPoster': ... - def on_success(self, callback: t.Any = None) -> t.Any: + def on_success(self, callback: t.Any = None) -> t.Union[t.Any, 'AutoPoster']: """ Registers an autopost success callback. The callback can be either sync or async. The callback is not required to take in arguments, but you can have injected :obj:`~.data.data`. This method can be used as a decorator or a decorator factory. - :Example: - .. code-block:: python + .. code-block:: python + + # The following are valid. + autoposter = client.autopost().on_success(lambda: print('Success!')) - # The following are valid. - autopost = dblclient.autopost().on_success(lambda: print("Success!")) - # Used as decorator, the decorated function will become the AutoPoster object. - @autopost.on_success - def autopost(): - ... + # Used as decorator, the decorated function will become the AutoPoster object. + @autoposter.on_success + def on_success() -> None: ... - # Used as decorator factory, the decorated function will still be the function itself. - @autopost.on_success() - def on_success(): - ... + + # Used as decorator factory, the decorated function will still be the function itself. + @autoposter.on_success() + def on_success() -> None: ... + + :param callback: The autoposter's new success callback. + :type callback: Any + + :returns: The object itself if ``callback`` is not :py:obj:`None`, otherwise the decorator function. + :rtype: Union[Any, :class:`.AutoPoster`] """ + if callback is not None: self._success = callback + return self def decorator(callback: CallbackT) -> CallbackT: self._success = callback + return callback return decorator @t.overload - def on_error(self, callback: None) -> t.Callable[[CallbackT], CallbackT]: - ... + def on_error(self, callback: None) -> t.Callable[[CallbackT], CallbackT]: ... @t.overload - def on_error(self, callback: CallbackT) -> "AutoPoster": - ... + def on_error(self, callback: CallbackT) -> 'AutoPoster': ... - def on_error(self, callback: t.Any = None) -> t.Any: + def on_error(self, callback: t.Any = None) -> t.Union[t.Any, 'AutoPoster']: """ Registers an autopost error callback. The callback can be either sync or async. - The callback is expected to take in the exception being raised, you can also - have injected :obj:`~.data.data`. + The callback is expected to take in the exception being raised, you can also have injected :obj:`~.data.data`. This method can be used as a decorator or a decorator factory. - Note: - If you don't provide an error callback, the default error handler will be called. + Note that if you don't provide an error callback, the default error handler will be called. - :Example: - .. code-block:: python + .. code-block:: python - # The following are valid. - autopost = dblclient.autopost().on_error(lambda exc: print("Failed posting stats!", exc)) + # The following are valid. + autoposter = client.autopost().on_error(lambda err: print(f'Error! {err!r}')) - # Used as decorator, the decorated function will become the AutoPoster object. - @autopost.on_error - def autopost(exc: Exception): - ... - # Used as decorator factory, the decorated function will still be the function itself. - @autopost.on_error() - def on_error(exc: Exception): - ... + # Used as decorator, the decorated function will become the AutoPoster object. + @autoposter.on_error + def on_error(err: Exception) -> None: ... + + + # Used as decorator factory, the decorated function will still be the function itself. + @autoposter.on_error() + def on_error(err: Exception) -> None: ... + + :param callback: The autoposter's new error callback. + :type callback: Any + + :returns: The object itself if ``callback`` is not :py:obj:`None`, otherwise the decorator function. + :rtype: Union[Any, :class:`.AutoPoster`] """ if callback is not None: self._error = callback + return self def decorator(callback: CallbackT) -> CallbackT: self._error = callback + return callback return decorator @t.overload - def stats(self, callback: None) -> t.Callable[[StatsCallbackT], StatsCallbackT]: - ... + def stats(self, callback: None) -> t.Callable[[StatsCallbackT], StatsCallbackT]: ... @t.overload - def stats(self, callback: StatsCallbackT) -> "AutoPoster": - ... + def stats(self, callback: StatsCallbackT) -> 'AutoPoster': ... - def stats(self, callback: t.Any = None) -> t.Any: + def stats(self, callback: t.Any = None) -> t.Union[t.Any, 'AutoPoster']: """ - Registers a function that returns an instance of :obj:`~.types.StatsWrapper`. + Registers a function that returns an instance of :class:`.StatsWrapper`. The callback can be either sync or async. - The callback can be either sync or async. The callback is not required to take in arguments, but you can have injected :obj:`~.data.data`. This method can be used as a decorator or a decorator factory. - :Example: - .. code-block:: python + .. code-block:: python - import topgg + # The following are valid. + autoposter = client.autopost().stats(lambda: topgg.StatsWrapper(bot.server_count)) - # In this example, we fetch the stats from a Discord client instance. - client = Client(...) - dblclient = topgg.DBLClient(TOKEN).set_data(client) - autopost = ( - dblclient - .autopost() - .on_success(lambda: print("Successfully posted the stats!") - ) - @autopost.stats() - def get_stats(client: Client = topgg.data(Client)): - return topgg.StatsWrapper(guild_count=len(client.guilds), shard_count=len(client.shards)) + # Used as decorator, the decorated function will become the AutoPoster object. + @autoposter.stats + def get_stats() -> topgg.StatsWrapper: + return topgg.StatsWrapper(bot.server_count) - # somewhere after the event loop has started - autopost.start() + + # Used as decorator factory, the decorated function will still be the function itself. + @autoposter.stats() + def get_stats() -> topgg.StatsWrapper: + return topgg.StatsWrapper(bot.server_count) + + :param callback: The autoposter's new statistics callback. + :type callback: Any + + :returns: The object itself if ``callback`` is not :py:obj:`None`, otherwise the decorator function. + :rtype: Union[Any, :class:`.AutoPoster`] """ + if callback is not None: self._stats = callback + return self def decorator(callback: StatsCallbackT) -> StatsCallbackT: self._stats = callback + return callback return decorator @@ -218,118 +222,124 @@ def decorator(callback: StatsCallbackT) -> StatsCallbackT: @property def interval(self) -> float: """The interval between posting stats.""" + return self._interval @interval.setter def interval(self, seconds: t.Union[float, datetime.timedelta]) -> None: - """Alias to :meth:`~.autopost.AutoPoster.set_interval`.""" + """Alias of :meth:`.AutoPoster.set_interval`.""" + self.set_interval(seconds) - def set_interval(self, seconds: t.Union[float, datetime.timedelta]) -> "AutoPoster": + def set_interval(self, seconds: t.Union[float, datetime.timedelta]) -> 'AutoPoster': """ Sets the interval between posting stats. - Args: - seconds (:obj:`typing.Union` [ :obj:`float`, :obj:`datetime.timedelta` ]) - The interval. + :param seconds: The interval in seconds. + :type seconds: Union[:py:class:`float`, :py:class:`~datetime.timedelta`] + + :exception ValueError: The provided interval is less than 900 seconds. - Raises: - :obj:`ValueError` - If the provided interval is less than 900 seconds. + :returns: The object itself. + :rtype: :class:`.AutoPoster` """ + if isinstance(seconds, datetime.timedelta): seconds = seconds.total_seconds() if seconds < 900: - raise ValueError("interval must be greated than 900 seconds.") + raise ValueError('interval must be greated than 900 seconds.') self._interval = seconds + return self @property def is_running(self) -> bool: - """Whether or not the autopost is running.""" + """Whether the autoposter is running.""" + return self._task is not None and not self._task.done() def _refresh_state(self) -> None: self._task = None self._stopping = False - def _fut_done_callback(self, future: "asyncio.Future") -> None: + def _fut_done_callback(self, future: 'asyncio.Future') -> None: self._refresh_state() + if future.cancelled(): return + future.exception() async def _internal_loop(self) -> None: try: while 1: stats = await self.client._invoke_callback(self._stats) + try: await self.client.post_guild_count(stats) except Exception as err: await self.client._invoke_callback(self._error, err) + if isinstance(err, errors.HTTPException) and err.code == 401: raise err from None else: - on_success = getattr(self, "_success", None) - if on_success: + if on_success := getattr(self, '_success', None): await self.client._invoke_callback(on_success) if self._stopping: - return None + return await asyncio.sleep(self.interval) finally: self._refresh_state() - def start(self) -> "asyncio.Task[None]": + def start(self) -> 'asyncio.Task[None]': """ - Starts the autoposting loop. + Starts the autoposter loop. + + Note that this method must be called when the event loop is already running! - Note: - This method must be called when the event loop has already running! + :exception TopGGException: There's no callback provided or the autoposter is already running. - Raises: - :obj:`~.errors.TopGGException` - If there's no callback provided or the autopost is already running. + :returns: The autoposter loop's :class:`~asyncio.Task`. + :rtype: :class:`~asyncio.Task`. """ - if not hasattr(self, "_stats"): + + if not hasattr(self, '_stats'): raise errors.TopGGException( - "you must provide a callback that returns the stats." + 'you must provide a callback that returns the stats.' ) - - if self.is_running: - raise errors.TopGGException("the autopost is already running.") + elif self.is_running: + raise errors.TopGGException('The autoposter is already running.') self._task = task = asyncio.ensure_future(self._internal_loop()) task.add_done_callback(self._fut_done_callback) + return task def stop(self) -> None: """ - Stops the autoposting loop. + Stops the autoposter loop. - Note: - This differs from :meth:`~.autopost.AutoPoster.cancel` - because this will post once before stopping as opposed to cancel immediately. + Not to be confused with :meth:`.AutoPoster.cancel`, which stops the loop immediately instead of waiting for another post before stopping. """ + if not self.is_running: - return None + return self._stopping = True def cancel(self) -> None: """ - Cancels the autoposting loop. + Cancels the autoposter loop. - Note: - This differs from :meth:`~.autopost.AutoPoster.stop` - because this will stop the loop right away. + Not to be confused with :meth:`.AutoPoster.stop`, which waits for another post before stopping instead of stopping the loop immediately. """ + if self._task is None: return self._task.cancel() self._refresh_state() - return None diff --git a/topgg/client.py b/topgg/client.py index 42c714e7..bed29d03 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -23,21 +23,21 @@ SOFTWARE. """ -__all__ = ["DBLClient"] - +from aiohttp import ClientResponseError, ClientSession, ClientTimeout +from typing import Any, Optional, overload, Union from collections import namedtuple from base64 import b64decode from time import time -import typing as t import binascii -import aiohttp +import warnings import asyncio import json +from . import errors, types from .autopost import AutoPoster -from . import errors, types, VERSION -from .data import DataContainerMixin from .ratelimiter import Ratelimiter, Ratelimiters +from .data import DataContainerMixin +from .version import VERSION BASE_URL = 'https://top.gg/api' @@ -45,42 +45,76 @@ class DBLClient(DataContainerMixin): - """Represents a client connection that connects to Top.gg. + """ + Interact with the API's endpoints. + + Examples: + + .. code-block:: python + + # Explicit cleanup + client = topgg.DBLClient(os.getenv('TOPGG_TOKEN')) - This class is used to interact with the Top.gg API. + # ... - .. _aiohttp session: https://aiohttp.readthedocs.io/en/stable/client_reference.html#client-session + await client.close() - Args: - token (:obj:`str`): Your Top.gg API Token. + # Implicit cleanup + async with topgg.DBLClient(os.getenv('TOPGG_TOKEN')) as client: + # ... - Keyword Args: - session (:class:`aiohttp.ClientSession`) - An `aiohttp session`_ to use for requests to the API. + :param token: Your Top.gg API token. + :type token: :py:class:`str` + :param session: Whether to use an existing :class:`~aiohttp.ClientSession` for requesting. Defaults to :py:obj:`None` (creates a new one instead) + :type session: Optional[:class:`~aiohttp.ClientSession`] + + :exception TypeError: ``token`` is not a :py:class:`str` or is empty. + :exception ValueError: ``token`` is not a valid API token. """ - __slots__ = ("id", "_token", "_autopost", "__session", "__own_session", "__ratelimiter", "__ratelimiters", "__current_ratelimit") + id: int + """This project's ID.""" + + __slots__: tuple[str, ...] = ( + '__own_session', + '__session', + '__token', + '__ratelimiter', + '__ratelimiters', + '__current_ratelimit', + '_autopost', + 'id', + ) def __init__( - self, - token: str, - *, - session: t.Optional[aiohttp.ClientSession] = None, - ) -> None: + self, token: str, *, session: Optional[ClientSession] = None, **kwargs + ): super().__init__() + if not isinstance(token, str) or not token: + raise TypeError('An API token is required to use this API.') + + if kwargs.pop('default_bot_id', None): + warnings.warn( + 'The default bot ID is now derived from the Top.gg API token itself', + DeprecationWarning, + ) + + for key in kwargs.keys(): + warnings.warn(f'Ignored keyword argument: {key}', DeprecationWarning) + + self._autopost = None self.__own_session = session is None - self.__session = session or aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=MAXIMUM_DELAY_THRESHOLD * 1000.0) + self.__session = session or ClientSession( + timeout=ClientTimeout(total=MAXIMUM_DELAY_THRESHOLD * 1000.0) ) - self._token = token - self._autopost: t.Optional[AutoPoster] = None + self.__token = token try: encoded_json = token.split('.')[1] encoded_json += '=' * (4 - (len(encoded_json) % 4)) encoded_json = json.loads(b64decode(encoded_json)) - + self.id = int(encoded_json['id']) except (IndexError, ValueError, binascii.Error, json.decoder.JSONDecodeError): raise ValueError('Got a malformed API token.') from None @@ -93,15 +127,27 @@ def __init__( self.__ratelimiters = Ratelimiters(self.__ratelimiter) self.__current_ratelimit = None + def __repr__(self) -> str: + return f'<{__class__.__name__} {self.__session!r}>' + + def __int__(self) -> int: + return self.id + + @property + def is_closed(self) -> bool: + """Whether the client is closed.""" + + return self.__session.closed + async def __request( self, method: str, path: str, - params: t.Optional[dict] = None, - body: t.Optional[dict] = None, + params: Optional[dict] = None, + body: Optional[dict] = None, ) -> dict: if self.is_closed: - raise errors.ClientStateException('Client session is already closed.') + raise errors.ClientStateException('DBLClient session is already closed.') if self.__current_ratelimit is not None: current_time = time() @@ -112,7 +158,9 @@ async def __request( self.__current_ratelimit = None ratelimiter = ( - self.__ratelimiters if path.startswith('/bots') else self.__ratelimiter.global_ + self.__ratelimiters + if path.startswith('/bots') + else self.__ratelimiter.global_ ) kwargs = {} @@ -123,13 +171,13 @@ async def __request( if params: kwargs['params'] = params - response = None + status = None retry_after = None output = None async with ratelimiter: try: - response = await self.__session.request( + async with self.__session.request( method, BASE_URL + path, headers={ @@ -138,21 +186,21 @@ async def __request( 'User-Agent': f'topggpy (https://github.com/top-gg-community/python-sdk {VERSION}) Python/', }, **kwargs, - ) - - retry_after = float(response.headers.get('Retry-After', 0)) + ) as resp: + status = resp.status + retry_after = float(resp.headers.get('Retry-After', 0)) - if 'json' in response.headers['Content-Type']: - try: - output = await response.json() - except json.decoder.JSONDecodeError: - pass + if 'json' in resp.headers['Content-Type']: + try: + output = await resp.json() + except json.decoder.JSONDecodeError: + pass - response.raise_for_status() + resp.raise_for_status() - return output - except aiohttp.ClientResponseError: - if response.status == 429: + return output + except ClientResponseError: + if status == 429: if retry_after > MAXIMUM_DELAY_THRESHOLD: self.__current_ratelimit = time() + retry_after @@ -162,183 +210,319 @@ async def __request( return await self.__request(method, path) - raise errors.HTTPException(response, output) from None + raise errors.HTTPException( + output and output.get('message', output.get('detail')), status + ) from None - @property - def is_closed(self) -> bool: - return self.__session.closed + async def get_bot_info(self, id: Optional[int]) -> types.BotData: + """ + Fetches a Discord bot from its ID. - async def get_weekend_status(self) -> bool: - """Gets weekend status from Top.gg. + Example: + + .. code-block:: python + + bot = await client.get_bot_info(432610292342587392) - Returns: - :obj:`bool`: The boolean value of weekend status. + :param id: The bot's ID. Defaults to your bot's ID. + :type id: Optional[:py:class:`int`] - Raises: - :obj:`~.errors.ClientStateException` - If the client has been closed. + :exception ClientStateException: The client is already closed. + :exception HTTPException: Such query does not exist or the client has received other unfavorable responses from the API. + :exception Ratelimited: Ratelimited from sending more requests. + + :returns: The requested bot. + :rtype: :class:`.BotData` """ - - response = await self.__request('GET', '/weekend') - return response['is_weekend'] + return types.BotData(await self.__request('GET', f'/bots/{id or self.id}')) + + async def get_bots( + self, + limit: Optional[int] = None, + offset: Optional[int] = None, + sort: Optional[types.SortBy] = None, + *args, + **kwargs, + ) -> types.BotsData: + """ + Fetches Discord bots that matches the specified query. + + Examples: + + .. code-block:: python + + # With defaults + bots = await client.get_bots() + + # With explicit arguments + bots = await client.get_bots(limit=250, offset=50, sort=topgg.SortBy.MONTHLY_VOTES) + + for bot in bots: + print(bot) + + :param limit: The maximum amount of bots to be returned. + :type limit: Optional[:py:class:`int`] + :param offset: The amount of bots to be skipped. + :type offset: Optional[:py:class:`int`] + :param sort: The criteria to sort results by. Results will always be descending. + :type sort: Optional[:class:`.SortBy`] + + :exception ClientStateException: The client is already closed. + :exception HTTPException: Received an unfavorable response from the API. + :exception Ratelimited: Ratelimited from sending more requests. + + :returns: The requested bots. + :rtype: :class:`.BotsData` + """ + + params = {} + + if limit is not None: + params['limit'] = max(min(limit, 500), 1) + + if offset is not None: + params['offset'] = max(min(offset, 499), 0) + + if sort is not None: + if not isinstance(sort, types.SortBy): + if isinstance(sort, str) and sort in types.SortBy: + warnings.warn( + 'The sort argument now expects a SortBy enum, not a str', + DeprecationWarning, + ) + + params['sort'] = sort + else: + raise TypeError( + f'Expected sort to be a SortBy enum, got {sort.__class__.__name__}.' + ) + else: + params['sort'] = sort.value + + for arg in args: + warnings.warn(f'Ignored extra argument: {arg!r}', DeprecationWarning) - @t.overload - async def post_guild_count(self, stats: types.StatsWrapper) -> None: - ... + for key in kwargs.keys(): + warnings.warn(f'Ignored keyword argument: {key}', DeprecationWarning) - @t.overload + return types.BotsData(await self.__request('GET', '/bots', params=params)) + + async def get_guild_count(self) -> Optional[types.BotStatsData]: + """ + Fetches your Discord bot's posted statistics. + + Example: + + .. code-block:: python + + stats = await client.get_guild_count() + server_count = stats.server_count + + :exception ClientStateException: The client is already closed. + :exception HTTPException: Received an unfavorable response from the API. + :exception Ratelimited: Ratelimited from sending more requests. + + :returns: The posted statistics. + :rtype: Optional[:py:class:`.BotStatsData`] + """ + + stats = await self.__request('GET', '/bots/stats') + + return stats and types.BotStatsData(stats) + + @overload + async def post_guild_count(self, stats: types.StatsWrapper) -> None: ... + + @overload async def post_guild_count( self, *, - guild_count: t.Union[int, t.List[int]], - shard_count: t.Optional[int] = None, - shard_id: t.Optional[int] = None, - ) -> None: - ... + guild_count: Union[int, list[int]], + shard_count: Optional[int] = None, + shard_id: Optional[int] = None, + ) -> None: ... async def post_guild_count( - self, - stats: t.Any = None, - *, - guild_count: t.Any = None, + self, stats: Any = None, *, guild_count: Any = None, **kwargs ) -> None: - """Posts your bot's guild count and shards info to Top.gg. + """ + Updates the statistics in your Discord bot's Top.gg page. - .. _0 based indexing : https://en.wikipedia.org/wiki/Zero-based_numbering + Example: - Warning: - You can't provide both args and kwargs at once. + .. code-block:: python - Args: - stats (:obj:`~.types.StatsWrapper`) - An instance of StatsWrapper containing guild_count, shard_count, and shard_id. + await client.post_guild_count(topgg.StatsWrapper(bot.server_count)) - Keyword Arguments: - guild_count (:obj:`typing.Optional` [:obj:`typing.Union` [ :obj:`int`, :obj:`list` [ :obj:`int` ]]]) - Number of guilds the bot is in. Applies the number to a shard instead if shards are specified. - If not specified, length of provided client's property `.guilds` will be posted. + :param stats: The updated statistics. + :type stats: :class:`.StatsWrapper` + :param guild_count: The updated server count. + :type guild_count: Union[:py:class:`int`, list[:py:class:`int`]] - Raises: - TypeError - If no argument is provided. - :obj:`~.errors.ClientStateException` - If the client has been closed. + :exception ValueError: Got an invalid server count. + :exception ClientStateException: The client is already closed. + :exception HTTPException: Received an unfavorable response from the API. + :exception Ratelimited: Ratelimited from sending more requests. """ - if stats: - guild_count = stats.guild_count - elif guild_count is None: - raise TypeError("stats or guild_count must be provided.") + + for key in kwargs.keys(): + warnings.warn(f'Ignored keyword argument: {key}', DeprecationWarning) + + if isinstance(stats, types.StatsWrapper): + guild_count = stats.server_count + + if not guild_count or guild_count <= 0: + raise ValueError(f'Got an invalid server count. Got {guild_count!r}.') await self.__request('POST', '/bots/stats', body={'server_count': guild_count}) - async def get_guild_count(self) -> types.BotStatsData: - """Gets a bot's guild count and shard info from Top.gg. + async def get_weekend_status(self) -> bool: + """ + Checks if the weekend multiplier is active, where a single vote counts as two. + + Example: + + .. code-block:: python + + is_weekend = await client.get_weekend_status() + + :exception ClientStateException: The client is already closed. + :exception HTTPException: Received an unfavorable response from the API. + :exception Ratelimited: Ratelimited from sending more requests. + + :returns: Whether the weekend multiplier is active. + :rtype: :py:class:`bool` + """ - Args: - bot_id (int) - ID of the bot you want to look up. Defaults to the provided Client object. + response = await self.__request('GET', '/weekend') - Returns: - :obj:`~.types.BotStatsData`: - The guild count and shards of a bot on Top.gg. + return response['is_weekend'] - Raises: - :obj:`~.errors.ClientStateException` - If the client has been closed. + async def get_bot_votes(self, page: int = 1) -> list[types.BriefUserData]: """ + Fetches your project's recent 100 unique voters. + + Examples: - response = await self.__request('GET', '/bots/stats') + .. code-block:: python - return types.BotStatsData(**response) + # First page + voters = await client.get_bot_votes() - async def get_bot_votes(self) -> t.List[types.BriefUserData]: - """Gets information about last 1000 votes for your bot on Top.gg. + # Subsequent pages + voters = await client.get_bot_votes(2) - Note: - This API endpoint is only available to the bot's owner. + for voter in voters: + print(voter) - Returns: - :obj:`list` [ :obj:`~.types.BriefUserData` ]: - Users who voted for your bot. + :param page: The page number. Each page can only have at most 100 voters. Defaults to 1. + :type page: :py:class:`int` - Raises: - :obj:`~.errors.ClientStateException` - If the client has been closed. + :exception ClientStateException: The client is already closed. + :exception HTTPException: Received an unfavorable response from the API. + :exception Ratelimited: Ratelimited from sending more requests. + + :returns: The requested voters. + :rtype: list[:class:`.BriefUserData`] """ - response = await self.__request('GET', f'/bots/{self.id}/votes') - - return [types.BriefUserData(**user) for user in response] + return [ + types.BriefUserData(data) + for data in await self.__request( + 'GET', f'/bots/{self.id}/votes', params={'page': max(page, 1)} + ) + ] - async def get_bot_info(self, bot_id: t.Optional[int] = None) -> types.BotData: - """This function is a coroutine. + async def get_user_info(self, user_id: int) -> types.UserData: + """ + Fetches a Top.gg user from their ID. - Gets information about a bot from Top.gg. + .. deprecated:: 1.5.0 + No longer supported by API v0. - Args: - bot_id (int) - ID of the bot to look up. Defaults to the provided Client object. + """ - Returns: - :obj:`~.types.BotData`: - Information on the bot you looked up. Returned data can be found - `here `_. + warnings.warn('get_user_info() is no longer supported', DeprecationWarning) - Raises: - :obj:`~.errors.ClientStateException` - If the client has been closed. + raise errors.HTTPException('User not found', 404) + + async def get_user_vote(self, id: int) -> bool: """ - bot_id = bot_id or self.id - response = await self.__request('GET', f'/bots/{bot_id}') - return types.BotData(**response) + Checks if a Top.gg user has voted for your project in the past 12 hours. + + Example: + + .. code-block:: python - async def get_user_vote(self, user_id: int) -> bool: - """Gets information about a user's vote for your bot on Top.gg. + has_voted = await client.get_user_vote(661200758510977084) - Args: - user_id (int) - ID of the user. + :param id: The user's ID. + :type id: :py:class:`int` - Returns: - :obj:`bool`: Info about the user's vote. + :exception ClientStateException: The client is already closed. + :exception HTTPException: The specified user has not logged in to Top.gg or the client has received other unfavorable responses from the API. + :exception Ratelimited: Ratelimited from sending more requests. - Raises: - :obj:`~.errors.ClientStateException` - If the client has been closed. + :returns: Whether the user has voted in the past 12 hours. + :rtype: :py:class:`bool` """ - data = await self.__request('GET', '/bots/check', params={'userId': user_id}) - - return bool(data["voted"]) + response = await self.__request('GET', '/bots/check', params={'userId': id}) + + return bool(response['voted']) def autopost(self) -> AutoPoster: - """Returns a helper instance for auto-posting. + """ + Creates an autoposter instance that automatically updates the statistics in your Discord bot's Top.gg page every few minutes. + + Note that the after you call this method, subsequent calls will always return the same instance. - Note: - The second time you call this method, it'll return the same instance - as the one returned from the first call. + .. code-block:: python - Returns: - :obj:`~.autopost.AutoPoster`: An instance of AutoPoster. + autoposter = client.autopost() + + @autoposter.stats + def get_stats() -> int: + return topgg.StatsWrapper(bot.server_count) + + @autoposter.on_success + def success() -> None: + print('Successfully posted statistics to the Top.gg API!') + + @autoposter.on_error + def error(exc: Exception) -> None: + print(f'Error: {exc!r}') + + autoposter.start() + + :returns: The autoposter instance. + :rtype: :class:`.AutoPoster` """ + if self._autopost is not None: return self._autopost self._autopost = AutoPoster(self) + return self._autopost - + async def close(self) -> None: - """Closes all connections.""" + """ + Closes the client. + + Example: + + .. code-block:: python + + await client.close() + """ - if self._autopost: - self._autopost.cancel() - - if self.__own_session and not self.__session.closed: + if self.__own_session and not self.is_closed: await self.__session.close() - async def __aenter__(self) -> "DBLClient": + async def __aenter__(self) -> 'DBLClient': return self async def __aexit__(self, *_, **__) -> None: - await self.close() \ No newline at end of file + await self.close() diff --git a/topgg/data.py b/topgg/data.py index 7126d3bf..4b17a6d3 100644 --- a/topgg/data.py +++ b/topgg/data.py @@ -1,131 +1,141 @@ -# The MIT License (MIT) - -# Copyright (c) 2021 Norizon - -# Permission is hereby granted, free of charge, to any person obtaining a -# copy of this software and associated documentation files (the "Software"), -# to deal in the Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, distribute, sublicense, -# and/or sell copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. - -__all__ = ["data", "DataContainerMixin"] +""" +The MIT License (MIT) + +Copyright (c) 2021 Norizon & Top.gg +Copyright (c) 2024-2025 null8626 & Top.gg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" import inspect import typing as t -from topgg.errors import TopGGException +from .errors import TopGGException -T = t.TypeVar("T") -DataContainerT = t.TypeVar("DataContainerT", bound="DataContainerMixin") + +T = t.TypeVar('T') +DataContainerT = t.TypeVar('DataContainerT', bound='DataContainerMixin') def data(type_: t.Type[T]) -> T: """ - Represents the injected data. This should be set as the parameter's default value. + The injected data. This should be set as the parameter's default value. - Args: - `type_` (:obj:`type` [ :obj:`T` ]) - The type of the injected data. + .. code-block:: python - Returns: - :obj:`T`: The injected data of type T. + client = topgg.DBLClient(os.getenv('BOT_TOKEN')).set_data(bot) + autoposter = client.autopost() - :Example: - .. code-block:: python - import topgg + @autoposter.stats() + def get_stats(bot: MyBot = topgg.data(MyBot)) -> topgg.StatsWrapper: + return topgg.StatsWrapper(bot.server_count) - # In this example, we fetch the stats from a Discord client instance. - client = Client(...) - dblclient = topgg.DBLClient(TOKEN).set_data(client) - autopost: topgg.AutoPoster = dblclient.autopost() + :param type_: The type of the injected data. + :type type_: Any - @autopost.stats() - def get_stats(client: Client = topgg.data(Client)): - return topgg.StatsWrapper(guild_count=len(client.guilds), shard_count=len(client.shards)) + :returns: The injected data. + :rtype: T """ + return t.cast(T, Data(type_)) class Data(t.Generic[T]): - __slots__ = ("type",) + __slots__ = ('type',) def __init__(self, type_: t.Type[T]) -> None: - self.type: t.Type[T] = type_ + self.type = type_ class DataContainerMixin: """ - A class that holds data. + A data container. - This is useful for injecting some data so that they're available - as arguments in your functions. + This is useful for injecting some data so that they're available as arguments in your functions. """ - __slots__ = ("_data",) + __slots__ = ('_data',) def __init__(self) -> None: - self._data: t.Dict[t.Type, t.Any] = {type(self): self} + self._data = {type(self): self} def set_data( self: DataContainerT, data_: t.Any, *, override: bool = False ) -> DataContainerT: """ - Sets data to be available in your functions. + Sets the data to be available in your functions. - Args: - `data_` (:obj:`typing.Any`) - The data to be injected. - override (:obj:`bool`) - Whether or not to override another instance that already exists. + :param data_: The data to be injected. + :type data_: Any + :param override: Whether to override another instance that already exists. Defaults to :py:obj:`False`. + :type override: :py:class:`bool` - Raises: - :obj:`~.errors.TopGGException` - If override is False and another instance of the same type exists. + :exception TopGGException: Override is :py:obj:`False` and another instance of the same type already exists. + + :returns: The object itself. + :rtype: :class:`.DataContainerMixin` """ + type_ = type(data_) + if not override and type_ in self._data: raise TopGGException( - f"{type_} already exists. If you wish to override it, pass True into the override parameter." + f'{type_} already exists. If you wish to override it, pass True into the override parameter.' ) self._data[type_] = data_ + return self @t.overload - def get_data(self, type_: t.Type[T]) -> t.Optional[T]: - ... + def get_data(self, type_: t.Type[T]) -> t.Optional[T]: ... @t.overload - def get_data(self, type_: t.Type[T], default: t.Any = None) -> t.Any: - ... + def get_data(self, type_: t.Type[T], default: t.Any = None) -> t.Any: ... def get_data(self, type_: t.Any, default: t.Any = None) -> t.Any: - """Gets the injected data.""" + """ + Gets the injected data. + + :param type_: The type of the injected data. + :type type_: Any + :param default: The default value in case the injected data does not exist. Defaults to :py:obj:`None`. + :type default: Any + + :returns: The injected data. + :rtype: Any + """ + return self._data.get(type_, default) async def _invoke_callback( self, callback: t.Callable[..., T], *args: t.Any, **kwargs: t.Any ) -> T: parameters: t.Mapping[str, inspect.Parameter] + try: parameters = inspect.signature(callback).parameters except (ValueError, TypeError): parameters = {} - signatures: t.Dict[str, Data] = { + signatures: dict[str, Data] = { k: v.default for k, v in parameters.items() if v.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD @@ -136,6 +146,7 @@ async def _invoke_callback( signatures[k] = self._resolve_data(v.type) res = callback(*args, **{**signatures, **kwargs}) + if inspect.isawaitable(res): return await res diff --git a/topgg/errors.py b/topgg/errors.py index d110126e..ce563ceb 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -23,77 +23,63 @@ SOFTWARE. """ -__all__ = [ - "TopGGException", - "ClientException", - "ClientStateException", - "Ratelimited", - "HTTPException", -] - -from typing import TYPE_CHECKING, Union - -if TYPE_CHECKING: - from aiohttp import ClientResponse +from typing import Optional class TopGGException(Exception): - """Base exception class for topggpy. + """An error coming from this SDK. Extends :py:class:`Exception`.""" - Ideally speaking, this could be caught to handle any exceptions thrown from this library. - """ + __slots__: tuple[str, ...] = () class ClientException(TopGGException): - """Exception that's thrown when an operation in the :class:`~.DBLClient` fails. + """An operation failure in :class:`.DBLClient`. Extends :class:`.TopGGException`.""" - These are usually for exceptions that happened due to user input. - """ + __slots__: tuple[str, ...] = () class ClientStateException(ClientException): - """Exception that's thrown when an operation happens in a closed :obj:`~.DBLClient` instance.""" - + """Attempted operation in a closed :class:`.DBLClient` instance. Extends :class:`.ClientException`.""" -class Ratelimited(TopGGException): - """Exception that's thrown when the client is ratelimited.""" - - __slots__: tuple[str, ...] = ('retry_after',) - - retry_after: float - """How long the client should wait in seconds before it could send requests again without receiving a 429.""" - - def __init__(self, retry_after: float): - self.retry_after = retry_after - - super().__init__( - f'Blocked from sending more requests, try again in {retry_after} seconds.' - ) + __slots__: tuple[str, ...] = () class HTTPException(TopGGException): - """Exception that's thrown when an HTTP request operation fails. + """HTTP request failure. Extends :class:`.TopGGException`.""" + + __slots__: tuple[str, ...] = ('message', 'code') + + message: Optional[str] + """The message returned from the API.""" - Attributes: - response (:class:`aiohttp.ClientResponse`) - The response of the failed HTTP request. - text (str) - The text of the error. Could be an empty string. - code (int) - The response status code. - """ + code: Optional[int] + """The status code returned from the API.""" - __slots__ = ("response", "text", "code") + def __init__(self, message: Optional[str], code: Optional[int]): + self.message = message + self.code = code - def __init__(self, response: "ClientResponse", message: Union[dict, str]) -> None: - self.response = response - self.code = response.status - self.text = message.get("message", message.get("detail", "")) if isinstance(message, dict) else message + super().__init__(f'Got {code}: {message!r}') - fmt = f"{self.response.reason} (status code: {self.response.status})" + def __repr__(self) -> str: + return f'<{__class__.__name__} message={self.message!r} code={self.code}>' - if self.text: - fmt = f"{fmt}: {self.text}" - super().__init__(fmt) +class Ratelimited(HTTPException): + """Ratelimited from sending more requests. Extends :class:`.HTTPException`.""" + + __slots__: tuple[str, ...] = ('retry_after',) + + retry_after: float + """How long the client should wait in seconds before it could send requests again without receiving a 429.""" + + def __init__(self, retry_after: float): + super().__init__( + f'Blocked from sending more requests, try again in {retry_after} seconds.', + 429, + ) + + self.retry_after = retry_after + def __repr__(self) -> str: + return f'<{__class__.__name__} retry_after={self.retry_after}>' diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index 87346418..8d5d635a 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -24,15 +24,14 @@ """ from collections.abc import Iterable -from types import TracebackType from collections import deque from time import time import asyncio +import typing + +if typing.TYPE_CHECKING: + from types import TracebackType -__all__ = [ - "Ratelimiter", - "Ratelimiters", -] class Ratelimiter: """Handles ratelimits for a specific endpoint.""" @@ -65,7 +64,7 @@ async def __aexit__( self, _exc_type: type[BaseException], _exc_val: BaseException, - _exc_tb: TracebackType, + _exc_tb: 'TracebackType', ) -> None: """Stores the previous request's timestamp.""" @@ -102,7 +101,7 @@ async def __aexit__( self, exc_type: type[BaseException], exc_val: BaseException, - exc_tb: TracebackType, + exc_tb: 'TracebackType', ) -> None: """Stores the previous request's timestamp.""" @@ -111,4 +110,4 @@ async def __aexit__( ratelimiter.__aexit__(exc_type, exc_val, exc_tb) for ratelimiter in self.__ratelimiters ) - ) \ No newline at end of file + ) diff --git a/topgg/types.py b/topgg/types.py index 2da13f95..a1c9990c 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -1,385 +1,486 @@ -# -*- coding: utf-8 -*- - -# The MIT License (MIT) - -# Copyright (c) 2021 Assanali Mukhanov - -# Permission is hereby granted, free of charge, to any person obtaining a -# copy of this software and associated documentation files (the "Software"), -# to deal in the Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, distribute, sublicense, -# and/or sell copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. - -__all__ = ["WidgetOptions", "StatsWrapper"] +""" +The MIT License (MIT) + +Copyright (c) 2021 Assanali Mukhanov & Top.gg +Copyright (c) 2024-2025 null8626 & Top.gg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" import dataclasses import typing as t -from datetime import datetime - -KT = t.TypeVar("KT") -VT = t.TypeVar("VT") -Colors = t.Dict[str, int] -Colours = Colors - - -def camel_to_snake(string: str) -> str: - return "".join(["_" + c.lower() if c.isupper() else c for c in string]).lstrip("_") - - -def parse_vote_dict(d: dict) -> dict: - data = d.copy() - - query = data.get("query", "").lstrip("?") - if query: - query_dict = {k: v for k, v in [pair.split("=") for pair in query.split("&")]} - data["query"] = DataDict(**query_dict) - else: - data["query"] = {} - - if "bot" in data: - data["bot"] = int(data["bot"]) - - elif "guild" in data: - data["guild"] = int(data["guild"]) - - for key, value in data.copy().items(): - converted_key = camel_to_snake(key) - if key != converted_key: - del data[key] - data[converted_key] = value - - return data - - -def parse_dict(d: dict) -> dict: - data = d.copy() - - for key, value in data.copy().items(): - if "id" in key.lower(): - if value == "": - value = None - else: - if isinstance(value, str) and value.isdigit(): - value = int(value) - else: - continue - elif value == "": - value = None - - converted_key = camel_to_snake(key) - if key != converted_key: - del data[key] - data[converted_key] = value - - return data +import warnings +from datetime import datetime +from enum import Enum -def parse_bot_dict(d: dict) -> dict: - data = parse_dict(d.copy()) - - if data.get("date") and not isinstance(data["date"], datetime): - data["date"] = datetime.strptime(data["date"], "%Y-%m-%dT%H:%M:%S.%fZ") - - if data.get("owners"): - data["owners"] = [int(e) for e in data["owners"]] - if data.get("guilds"): - data["guilds"] = [int(e) for e in data["guilds"]] - for key, value in data.copy().items(): - converted_key = camel_to_snake(key) - if key != converted_key: - del data[key] - data[converted_key] = value +T = t.TypeVar('T') - return data +def truthy_only(value: t.Optional[T]) -> t.Optional[T]: + if value: + return value -def parse_user_dict(d: dict) -> dict: - data = d.copy() - data["social"] = SocialData(**data.get("social", {})) +class WidgetProjectType(Enum): + """A Top.gg widget's project type.""" - return data + __slots__: tuple[str, ...] = () + DISCORD_BOT = 'discord/bot' + DISCORD_SERVER = 'discord/server' -def parse_bot_stats_dict(d: dict) -> dict: - data = d.copy() - if "server_count" not in data: - data["server_count"] = None - if "shards" not in data: - data["shards"] = [] - if "shard_count" not in data: - data["shard_count"] = None +class WidgetType(Enum): + """A Top.gg widget's type.""" - return data + __slots__: tuple[str, ...] = () + LARGE = 'large' + VOTES = 'votes' + OWNER = 'owner' + SOCIAL = 'social' -class DataDict(dict, t.MutableMapping[KT, VT]): - """Base class used to represent received data from the API. - Every data model subclasses this class. - """ +class WidgetOptions: + """Top.gg widget creation options.""" - def __init__(self, **kwargs: VT) -> None: - super().__init__(**parse_dict(kwargs)) - self.__dict__ = self + __slots__: tuple[str, ...] = ('id', 'project_type', 'type') + id: int + """This widget's project ID.""" -class WidgetOptions(DataDict[str, t.Any]): - """Model that represents widget options that are passed to Top.gg widget URL generated via - :meth:`DBLClient.generate_widget`.""" + project_type: WidgetProjectType + """This widget's project type.""" - id: t.Optional[int] - """ID of a bot to generate the widget for. Must resolve to an ID of a listed bot when converted to a string.""" - colors: Colors - """A dictionary consisting of a parameter as a key and HEX color (type `int`) as value. ``color`` will be - appended to the key in case it doesn't end with ``color``.""" - noavatar: bool - """Indicates whether to exclude the bot's avatar from short widgets. Must be of type ``bool``. Defaults to - ``False``.""" - format: str - """Format to apply to the widget. Must be either ``png`` and ``svg``. Defaults to ``png``.""" - type: str - """Type of a short widget (``status``, ``servers``, ``upvotes``, and ``owner``). For large widget, - must be an empty string.""" + type: WidgetType + """This widget's type.""" def __init__( self, - id: t.Optional[int] = None, - format: t.Optional[str] = None, - type: t.Optional[str] = None, - noavatar: bool = False, - colors: t.Optional[Colors] = None, - colours: t.Optional[Colors] = None, + id: int, + project_type: WidgetProjectType, + type: WidgetType, + *args, + **kwargs, ): - super().__init__( - id=id or None, - format=format or "png", - type=type or "", - noavatar=noavatar or False, - colors=colors or colours or {}, - ) - - @property - def colours(self) -> Colors: - return self.colors - - @colours.setter - def colours(self, value: Colors) -> None: - self.colors = value - - def __setitem__(self, key: str, value: t.Any) -> None: - if key == "colours": - key = "colors" - super().__setitem__(key, value) - - def __getitem__(self, item: str) -> t.Any: - if item == "colours": - item = "colors" - return super().__getitem__(item) - - def get(self, key: str, default: t.Any = None) -> t.Any: - """:meta private:""" - if key == "colours": - key = "colors" - return super().get(key, default) - - -class BotData(DataDict[str, t.Any]): - """Model that contains information about a listed bot on top.gg. The data this model contains can be found `here - `__.""" + self.id = id + self.project_type = project_type + self.type = type + + for arg in args: + warnings.warn(f'Ignored extra argument: {arg!r}', DeprecationWarning) + + for key in kwargs.keys(): + warnings.warn(f'Ignored keyword argument: {key}', DeprecationWarning) + + def __repr__(self) -> str: + return f'<{__class__.__name__} id={self.id} project_type={self.project_type!r} type={self.type!r}>' + + +class BotData: + """A Discord bot listed on Top.gg.""" + + __slots__: tuple[str, ...] = ( + 'id', + 'topgg_id', + 'username', + 'discriminator', + 'avatar', + 'def_avatar', + 'prefix', + 'shortdesc', + 'longdesc', + 'tags', + 'website', + 'support', + 'github', + 'owners', + 'guilds', + 'invite', + 'date', + 'certified_bot', + 'vanity', + 'points', + 'monthly_points', + 'donatebotguildid', + 'server_count', + 'review_score', + 'review_count', + ) id: int - """The ID of the bot.""" + """This bot's Discord ID.""" + + topgg_id: int + """This bot's Top.gg ID.""" username: str - """The username of the bot.""" + """This bot's username.""" discriminator: str - """The discriminator of the bot.""" + """This bot's discriminator.""" - avatar: t.Optional[str] - """The avatar hash of the bot.""" + avatar: str + """This bot's avatar URL.""" def_avatar: str - """The avatar hash of the bot's default avatar.""" + """This bot's default avatar hash.""" prefix: str - """The prefix of the bot.""" + """This bot's prefix.""" shortdesc: str - """The brief description of the bot.""" + """This bot's short description.""" longdesc: t.Optional[str] - """The long description of the bot.""" + """This bot's HTML/Markdown long description.""" - tags: t.List[str] - """The tags the bot has.""" + tags: list[str] + """This bot's tags.""" website: t.Optional[str] - """The website of the bot.""" + """This bot's website URL.""" support: t.Optional[str] - """The invite code of the bot's support server.""" + """This bot's support URL.""" github: t.Optional[str] - """The GitHub URL of the repo of the bot.""" + """This bot's GitHub repository URL.""" - owners: t.List[int] - """The IDs of the owners of the bot.""" + owners: list[int] + """This bot's owner IDs.""" - guilds: t.List[int] - """The guilds the bot is in.""" + guilds: list[int] + """This bot's list of servers.""" invite: t.Optional[str] - """The invite URL of the bot.""" + """This bot's invite URL.""" date: datetime - """The time the bot was added.""" + """This bot's submission date.""" certified_bot: bool - """Whether or not the bot is certified.""" + """Whether this bot is certified.""" vanity: t.Optional[str] - """The vanity URL of the bot.""" + """This bot's Top.gg vanity code.""" points: int - """The amount of the votes the bot has.""" + """The amount of votes this bot has.""" monthly_points: int - """The amount of the votes the bot has this month.""" + """The amount of votes this bot has this month.""" donatebotguildid: int - """The guild ID for the donatebot setup.""" + """This bot's donatebot setup server ID.""" + + server_count: t.Optional[str] + """This bot's posted server count.""" + + review_score: float + """This bot's average review score out of 5.""" + + review_count: int + """This bot's review count.""" + + def __init__(self, json: dict): + self.id = int(json['clientid']) + self.topgg_id = int(json['id']) + self.username = json['username'] + self.discriminator = '0' + self.avatar = json['avatar'] + self.def_avatar = '' + self.prefix = json['prefix'] + self.shortdesc = json['shortdesc'] + self.longdesc = truthy_only(json.get('longdesc')) + self.tags = json['tags'] + self.website = truthy_only(json.get('website')) + self.support = truthy_only(json.get('support')) + self.github = truthy_only(json.get('github')) + self.owners = [int(id) for id in json['owners']] + self.guilds = [] + self.invite = truthy_only(json.get('invite')) + self.date = datetime.fromisoformat(json['date'].replace('Z', '+00:00')) + self.certified_bot = False + self.vanity = truthy_only(json.get('vanity')) + self.points = json['points'] + self.monthly_points = json['monthlyPoints'] + self.donatebotguildid = 0 + self.server_count = json.get('server_count') + self.review_score = json['reviews']['averageScore'] + self.review_count = json['reviews']['count'] + + def __repr__(self) -> str: + return f'<{__class__.__name__} id={self.id} username={self.username!r} points={self.points} monthly_points={self.monthly_points} server_count={self.server_count}>' + + def __int__(self) -> int: + return self.id + + def __eq__(self, other: 'BotData') -> bool: + if isinstance(other, __class__): + return self.id == other.id + + return NotImplemented + + +class BotsData: + """A list of Discord bot's listed on Top.gg.""" + + __slots__: tuple[str, ...] = ('results', 'limit', 'offset', 'count', 'total') + + results: list[BotData] + """The list of bots returned.""" + + limit: int + """The maximum amount of bots returned.""" + + offset: int + """The amount of bots skipped.""" + + count: int + """The amount of bots returned. Akin to len(results).""" - def __init__(self, **kwargs: t.Any): - super().__init__(**parse_bot_dict(kwargs)) + total: int + """The amount of bots that matches the specified query. May be equal or greater than count or len(results).""" + def __init__(self, json: dict): + self.results = [BotData(bot) for bot in json['results']] + self.limit = json['limit'] + self.offset = json['offset'] + self.count = json['count'] + self.total = json['total'] -class BotStatsData(DataDict[str, t.Any]): - """Model that contains information about a listed bot's guild and shard count.""" + def __repr__(self) -> str: + return f'<{__class__.__name__} results={self.results!r} count={self.count} total={self.total}>' + + def __iter__(self) -> t.Iterable[BotData]: + return iter(self.results) + + def __len__(self) -> int: + return self.count + + +class BotStatsData: + """A Discord bot's statistics.""" + + __slots__: tuple[str, ...] = ('server_count', 'shards', 'shard_count') server_count: t.Optional[int] """The amount of servers the bot is in.""" - shards: t.List[int] + + shards: list[int] """The amount of servers the bot is in per shard.""" + shard_count: t.Optional[int] - """The amount of shards a bot has.""" + """The amount of shards the bot has.""" + + def __init__(self, json: dict): + self.server_count = json.get('server_count') + self.shards = [] + self.shard_count = None + + def __repr__(self) -> str: + return f'<{__class__.__name__} server_count={self.server_count}>' + + def __int__(self) -> int: + return self.server_count + + def __eq__(self, other: 'BotStatsData') -> bool: + if isinstance(other, __class__): + return self.server_count == other.server_count - def __init__(self, **kwargs: t.Any): - super().__init__(**parse_bot_stats_dict(kwargs)) + return NotImplemented -class BriefUserData(DataDict[str, t.Any]): - """Model that contains brief information about a Top.gg user.""" +class BriefUserData: + """A Top.gg user's brief information.""" + + __slots__: tuple[str, ...] = ('id', 'username', 'avatar') id: int - """The Discord ID of the user.""" + """This user's ID.""" + username: str - """The Discord username of the user.""" + """This user's username.""" + avatar: str - """The Discord avatar URL of the user.""" + """This user's avatar URL.""" + + def __init__(self, json: dict): + self.id = int(json['id']) + self.username = json['username'] + self.avatar = json['avatar'] + + def __repr__(self) -> str: + return f'<{__class__.__name__} id={self.id} username={self.username!r}>' + + def __int__(self) -> int: + return self.id - def __init__(self, **kwargs: t.Any): - if kwargs["id"].isdigit(): - kwargs["id"] = int(kwargs["id"]) - super().__init__(**kwargs) + def __eq__(self, other: 'BriefUserData') -> bool: + if isinstance(other, __class__): + return self.id == other.id + return NotImplemented -class SocialData(DataDict[str, str]): - """Model that contains social information about a top.gg user.""" + +class SocialData: + """A Top.gg user's socials.""" + + __slots__: tuple[str, ...] = ('youtube', 'reddit', 'twitter', 'instagram', 'github') youtube: str - """The YouTube channel ID of the user.""" + """This user's YouTube channel.""" + reddit: str - """The Reddit username of the user.""" + """This user's Reddit username.""" + twitter: str - """The Twitter username of the user.""" + """This user's Twitter username.""" + instagram: str - """The Instagram username of the user.""" + """This user's Instagram username.""" + github: str - """The GitHub username of the user.""" + """This user's GitHub username.""" + +class UserData: + """A Top.gg user.""" -class UserData(DataDict[str, t.Any]): - """Model that contains information about a top.gg user. The data this model contains can be found `here - `__.""" + __slots__: tuple[str, ...] = ( + 'id', + 'username', + 'discriminator', + 'social', + 'color', + 'supporter', + 'certified_dev', + 'mod', + 'web_mod', + 'admin', + ) id: int - """The ID of the user.""" + """This user's ID.""" username: str - """The username of the user.""" + """This user's username.""" discriminator: str - """The discriminator of the user.""" + """This user's discriminator.""" social: SocialData - """The social data of the user.""" + """This user's social links.""" color: str - """The custom hex color of the user.""" + """This user's profile color.""" supporter: bool - """Whether or not the user is a supporter.""" + """Whether this user is a Top.gg supporter.""" certified_dev: bool - """Whether or not the user is a certified dev.""" + """Whether this user is a Top.gg certified developer.""" mod: bool - """Whether or not the user is a Top.gg mod.""" + """Whether this user is a Top.gg moderator.""" web_mod: bool - """Whether or not the user is a Top.gg web mod.""" + """Whether this user is a Top.gg website moderator.""" admin: bool - """Whether or not the user is a Top.gg admin.""" + """Whether this user is a Top.gg website administrator.""" + + +class SortBy(Enum): + """Supported sorting criterias in :meth:`.DBLClient.get_bots`.""" + + __slots__: tuple[str, ...] = () + + ID = 'id' + """Sorts results based on each bot's ID.""" - def __init__(self, **kwargs: t.Any): - super().__init__(**parse_user_dict(kwargs)) + SUBMISSION_DATE = 'date' + """Sorts results based on each bot's submission date.""" + MONTHLY_VOTES = 'monthlyPoints' + """Sorts results based on each bot's monthly vote count.""" -class VoteDataDict(DataDict[str, t.Any]): - """Base model that represents received information from Top.gg via webhooks.""" + +class VoteDataDict: + """A dispatched Top.gg project vote event.""" + + __slots__: tuple[str, ...] = ('type', 'user', 'query') type: str - """Type of the action (``upvote`` or ``test``).""" + """Vote event type. ``upvote`` (invoked from the vote page by a user) or ``test`` (invoked explicitly by the developer for testing.)""" + user: int - """ID of the voter.""" - query: DataDict - """Query parameters in :obj:`~.DataDict`.""" + """The ID of the user who voted.""" + + query: t.Optional[str] + """Query strings found on the vote page.""" + + def __init__(self, json: dict): + self.type = json['type'] + self.user = int(json['user']) + self.query = json.get('query') - def __init__(self, **kwargs: t.Any): - super().__init__(**parse_vote_dict(kwargs)) + def __repr__(self) -> str: + return f'<{__class__.__name__} type={self.type!r} user={self.user}>' class BotVoteData(VoteDataDict): - """Model that contains information about a bot vote.""" + """A dispatched Top.gg Discord bot vote event. Extends :class:`.VoteDataDict`.""" + + __slots__: tuple[str, ...] = ('bot', 'is_weekend') bot: int - """ID of the bot the user voted for.""" + """The ID of the bot that received a vote.""" + is_weekend: bool - """Boolean value indicating whether the action was done on a weekend.""" + """Whether the weekend multiplier is active or not, meaning a single vote counts as two.""" + + def __init__(self, json: dict): + super().__init__(json) + + self.bot = int(json['bot']) + self.is_weekend = json['isWeekend'] + + def __repr__(self) -> str: + return f'<{__class__.__name__} type={self.type!r} user={self.user} is_weekend={self.is_weekend}>' class GuildVoteData(VoteDataDict): - """Model that contains information about a guild vote.""" + """ "A dispatched Top.gg Discord server vote event. Extends :class:`.VoteDataDict`.""" + + __slots__: tuple[str, ...] = ('guild',) guild: int - """ID of the guild the user voted for.""" + """The ID of the server that received a vote.""" + + def __init__(self, json: dict): + super().__init__(json) + + self.guild = int(json['guild']) ServerVoteData = GuildVoteData @@ -387,11 +488,11 @@ class GuildVoteData(VoteDataDict): @dataclasses.dataclass class StatsWrapper: - guild_count: int - """The guild count.""" + server_count: t.Optional[int] + """The amount of servers the bot is in.""" shard_count: t.Optional[int] = None - """The shard count.""" + """The amount of shards the bot has.""" shard_id: t.Optional[int] = None """The shard ID the guild count belongs to.""" diff --git a/topgg/version.py b/topgg/version.py index 91c27a95..84262d36 100644 --- a/topgg/version.py +++ b/topgg/version.py @@ -1 +1 @@ -VERSION = "3.0.0" \ No newline at end of file +VERSION = '1.5.0' diff --git a/topgg/webhook.py b/topgg/webhook.py index 4b94ec2b..acaa5130 100644 --- a/topgg/webhook.py +++ b/topgg/webhook.py @@ -1,111 +1,92 @@ -# -*- coding: utf-8 -*- - -# The MIT License (MIT) - -# Copyright (c) 2021 Assanali Mukhanov - -# Permission is hereby granted, free of charge, to any person obtaining a -# copy of this software and associated documentation files (the "Software"), -# to deal in the Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, distribute, sublicense, -# and/or sell copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. - -__all__ = [ - "endpoint", - "BoundWebhookEndpoint", - "WebhookEndpoint", - "WebhookManager", - "WebhookType", -] +""" +The MIT License (MIT) + +Copyright (c) 2021 Assanali Mukhanov & Top.gg +Copyright (c) 2024-2025 null8626 & Top.gg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" import enum import typing as t - -import aiohttp from aiohttp import web -from topgg.errors import TopGGException - +from .errors import TopGGException from .data import DataContainerMixin from .types import BotVoteData, GuildVoteData if t.TYPE_CHECKING: from aiohttp.web import Request, StreamResponse -T = t.TypeVar("T", bound="WebhookEndpoint") -_HandlerT = t.Callable[["Request"], t.Awaitable["StreamResponse"]] +T = t.TypeVar('T', bound='WebhookEndpoint') +_HandlerT = t.Callable[['Request'], t.Awaitable['StreamResponse']] class WebhookType(enum.Enum): - """An enum that represents the type of an endpoint.""" + """Marks the type of a webhook endpoint.""" + + __slots__: tuple[str, ...] = () BOT = enum.auto() - """Marks the endpoint as a bot webhook.""" + """Marks the endpoint as a Discord bot webhook.""" GUILD = enum.auto() - """Marks the endpoint as a guild webhook.""" + """Marks the endpoint as a Discord server webhook.""" class WebhookManager(DataContainerMixin): - """ - A class for managing Top.gg webhooks. - """ + """A Top.gg webhook manager.""" - __app: web.Application - _webserver: web.TCPSite - _is_closed: bool - __slots__ = ("__app", "_webserver", "_is_running") + __slots__: tuple[str, ...] = ('__app', '_webserver', '_is_running') def __init__(self) -> None: super().__init__() + self.__app = web.Application() self._is_running = False @t.overload - def endpoint(self, endpoint_: None = None) -> "BoundWebhookEndpoint": - ... + def endpoint(self, endpoint_: None = None) -> 'BoundWebhookEndpoint': ... @t.overload - def endpoint(self, endpoint_: "WebhookEndpoint") -> "WebhookManager": - ... + def endpoint(self, endpoint_: 'WebhookEndpoint') -> 'WebhookManager': ... - def endpoint(self, endpoint_: t.Optional["WebhookEndpoint"] = None) -> t.Any: - """Helper method that returns a WebhookEndpoint object. + def endpoint( + self, endpoint_: t.Optional['WebhookEndpoint'] = None + ) -> t.Union['WebhookManager', 'BoundWebhookEndpoint']: + """ + A helper method that returns a :class:`.WebhookEndpoint` object. - Args: - `endpoint_` (:obj:`typing.Optional` [ :obj:`WebhookEndpoint` ]) - The endpoint to add. + :param endpoint_: The endpoint to add. + :type endpoint_: Optional[:class:`.WebhookEndpoint`] - Returns: - :obj:`typing.Union` [ :obj:`WebhookManager`, :obj:`BoundWebhookEndpoint` ]: - An instance of :obj:`WebhookManager` if endpoint was provided, - otherwise :obj:`BoundWebhookEndpoint`. + :exception TopGGException: If the endpoint is not :py:obj:`None` and is not an instance of :class:`.WebhookEndpoint`. - Raises: - :obj:`~.errors.TopGGException` - If the endpoint is lacking attributes. + :returns: An instance of :class:`.WebhookManager` if an endpoint was provided, otherwise :class:`.BoundWebhookEndpoint`. + :rtype: Union[:class:`.WebhookManager`, :class:`.BoundWebhookEndpoint`] """ - if endpoint_: - if not hasattr(endpoint_, "_callback"): - raise TopGGException("endpoint missing callback.") - - if not hasattr(endpoint_, "_type"): - raise TopGGException("endpoint missing type.") - if not hasattr(endpoint_, "_route"): - raise TopGGException("endpoint missing route.") + if endpoint_: + if not isinstance(endpoint_, WebhookEndpoint): + raise TopGGException( + f'endpoint_ must be an instance of WebhookEndpoint, got {endpoint_.__class__.__name__}.' + ) self.app.router.add_post( endpoint_._route, @@ -113,56 +94,60 @@ def endpoint(self, endpoint_: t.Optional["WebhookEndpoint"] = None) -> t.Any: endpoint_._type, endpoint_._auth, endpoint_._callback ), ) + return self return BoundWebhookEndpoint(manager=self) async def start(self, port: int) -> None: - """Runs the webhook. + """ + Runs the webhook. - Args: - port (int) - The port to run the webhook on. + :param port: The port to use. + :type port: :py:class:`int` """ + runner = web.AppRunner(self.__app) await runner.setup() - self._webserver = web.TCPSite(runner, "0.0.0.0", port) + + self._webserver = web.TCPSite(runner, '0.0.0.0', port) await self._webserver.start() + self._is_running = True @property def is_running(self) -> bool: - """Returns whether or not the webserver is running.""" + """Whether the webserver is running.""" + return self._is_running @property def app(self) -> web.Application: - """Returns the internal web application that handles webhook requests. + """The internal :class:`~aiohttp.web.Application` that handles web requests.""" - Returns: - :class:`aiohttp.web.Application`: - The internal web application. - """ return self.__app async def close(self) -> None: """Stops the webhook.""" + await self._webserver.stop() self._is_running = False def _get_handler( self, type_: WebhookType, auth: str, callback: t.Callable[..., t.Any] ) -> _HandlerT: - async def _handler(request: aiohttp.web.Request) -> web.Response: - if request.headers.get("Authorization", "") != auth: - return web.Response(status=401, text="Unauthorized") + async def _handler(request: web.Request) -> web.Response: + if request.headers.get('Authorization', '') != auth: + return web.Response(status=401, text='Unauthorized') data = await request.json() + await self._invoke_callback( callback, - (BotVoteData if type_ is WebhookType.BOT else GuildVoteData)(**data), + (BotVoteData if type_ is WebhookType.BOT else GuildVoteData)(data), ) - return web.Response(status=200, text="OK") + + return web.Response(status=204, text='') return _handler @@ -171,104 +156,112 @@ async def _handler(request: aiohttp.web.Request) -> web.Response: class WebhookEndpoint: - """ - A helper class to setup webhook endpoint. - """ + """A helper class to setup a Top.gg webhook endpoint.""" - __slots__ = ("_callback", "_auth", "_route", "_type") + __slots__: tuple[str, ...] = ('_callback', '_auth', '_route', '_type') def __init__(self) -> None: - self._auth = "" + self._auth = '' def __call__(self, *args: t.Any, **kwargs: t.Any) -> t.Any: return self._callback(*args, **kwargs) def type(self: T, type_: WebhookType) -> T: - """Sets the type of this endpoint. + """ + Sets the type of this endpoint. - Args: - `type_` (:obj:`WebhookType`) - The type of the endpoint. + :param type_: The endpoint's new type. + :type type_: :class:`.WebhookType` - Returns: - :obj:`WebhookEndpoint` + :returns: The object itself. + :rtype: :class:`.WebhookEndpoint` """ + self._type = type_ + return self def route(self: T, route_: str) -> T: """ Sets the route of this endpoint. - Args: - `route_` (str) - The route of this endpoint. + :param route_: The endpoint's new route. + :type route_: :py:class:`str` - Returns: - :obj:`WebhookEndpoint` + :returns: The object itself. + :rtype: :class:`.WebhookEndpoint` """ + self._route = route_ + return self def auth(self: T, auth_: str) -> T: """ - Sets the auth of this endpoint. + Sets the password of this endpoint. - Args: - `auth_` (str) - The auth of this endpoint. + :param auth_: The endpoint's new password. + :type auth_: :py:class:`str` - Returns: - :obj:`WebhookEndpoint` + :returns: The object itself. + :rtype: :class:`.WebhookEndpoint` """ + self._auth = auth_ + return self @t.overload - def callback(self, callback_: None) -> t.Callable[[CallbackT], CallbackT]: - ... + def callback(self, callback_: None) -> t.Callable[[CallbackT], CallbackT]: ... @t.overload - def callback(self: T, callback_: CallbackT) -> T: - ... + def callback(self: T, callback_: CallbackT) -> T: ... - def callback(self, callback_: t.Any = None) -> t.Any: + def callback(self, callback_: t.Any = None) -> t.Union[t.Any, 'WebhookEndpoint']: """ - Registers a vote callback, called whenever this endpoint receives POST requests. + Registers a vote callback that gets called whenever this endpoint receives POST requests. The callback can be either sync or async. - The callback can be either sync or async. This method can be used as a decorator or a decorator factory. - :Example: - .. code-block:: python + .. code-block:: python - import topgg + webhook_manager = topgg.WebhookManager() - webhook_manager = topgg.WebhookManager() - endpoint = ( - topgg.WebhookEndpoint() - .type(topgg.WebhookType.BOT) - .route("/dblwebhook") - .auth("youshallnotpass") - ) + endpoint = ( + topgg.WebhookEndpoint() + .type(topgg.WebhookType.BOT) + .route('/dblwebhook') + .auth('youshallnotpass') + ) + + # The following are valid. + endpoint.callback(lambda vote: print(f'Got a vote: {vote!r}')) + + + # Used as decorator, the decorated function will become the WebhookEndpoint object. + @endpoint.callback + def on_vote(vote: topgg.BotVoteData) -> None: ... + + + # Used as decorator factory, the decorated function will still be the function itself. + @endpoint.callback() + def on_vote(vote: topgg.BotVoteData) -> None: ... - # The following are valid. - endpoint.callback(lambda vote_data: print("Receives a vote!", vote_data)) - # Used as decorator, the decorated function will become the WebhookEndpoint object. - @endpoint.callback - def endpoint(vote_data: topgg.BotVoteData): - ... + webhook_manager.endpoint(endpoint) - # Used as decorator factory, the decorated function will still be the function itself. - @endpoint.callback() - def on_vote(vote_data: topgg.BotVoteData): - ... + await webhook_manager.start(8080) - webhook_manager.endpoint(endpoint) + :param callback_: The endpoint's new vote callback. + :type callback_: Any + + :returns: The object itself if ``callback`` is not :py:obj:`None`, otherwise the object's own callback. + :rtype: Union[Any, :class:`.WebhookEndpoint`] """ + if callback_ is not None: self._callback = callback_ + return self return self.callback @@ -276,90 +269,92 @@ def on_vote(vote_data: topgg.BotVoteData): class BoundWebhookEndpoint(WebhookEndpoint): """ - A WebhookEndpoint with a WebhookManager bound to it. + A :class:`.WebhookEndpoint` with a :class:`.WebhookManager` bound to it. - You can instantiate this object using the :meth:`WebhookManager.endpoint` method. + You can instantiate this object using the :meth:`.WebhookManager.endpoint` method. - :Example: - .. code-block:: python + .. code-block:: python - import topgg + endpoint = ( + topgg.WebhookManager() + .endpoint() + .type(topgg.WebhookType.BOT) + .route('/dblwebhook') + .auth('youshallnotpass') + ) - webhook_manager = ( - topgg.WebhookManager() - .endpoint() - .type(topgg.WebhookType.BOT) - .route("/dblwebhook") - .auth("youshallnotpass") - ) + # The following are valid. + endpoint.callback(lambda vote: print(f'Got a vote: {vote!r}')) - # The following are valid. - endpoint.callback(lambda vote_data: print("Receives a vote!", vote_data)) - # Used as decorator, the decorated function will become the BoundWebhookEndpoint object. - @endpoint.callback - def endpoint(vote_data: topgg.BotVoteData): - ... + # Used as decorator, the decorated function will become the BoundWebhookEndpoint object. + @endpoint.callback + def on_vote(vote: topgg.BotVoteData) -> None: ... - # Used as decorator factory, the decorated function will still be the function itself. - @endpoint.callback() - def on_vote(vote_data: topgg.BotVoteData): - ... - endpoint.add_to_manager() + # Used as decorator factory, the decorated function will still be the function itself. + @endpoint.callback() + def on_vote(vote: topgg.BotVoteData) -> None: ... + + + endpoint.add_to_manager() + + await endpoint.manager.start(8080) + + :param manager: The webhook manager to use. + :type manager: :class:`.WebhookManager` """ - __slots__ = ("manager",) + __slots__: tuple[str, ...] = ('manager',) def __init__(self, manager: WebhookManager): super().__init__() + self.manager = manager def add_to_manager(self) -> WebhookManager: """ Adds this endpoint to the webhook manager. - Returns: - :obj:`WebhookManager` + :exception TopGGException: If the webhook manager is not :py:obj:`None` and is not an instance of :class:`.WebhookEndpoint`. - Raises: - :obj:`errors.TopGGException`: - If the object lacks attributes. + :returns: The webhook manager used. + :rtype: :class:`WebhookManager` """ + self.manager.endpoint(self) + return self.manager def endpoint( - route: str, type: WebhookType, auth: str = "" + route: str, type: WebhookType, auth: str = '' ) -> t.Callable[[t.Callable[..., t.Any]], WebhookEndpoint]: """ - A decorator factory for instantiating WebhookEndpoint. + A decorator factory for instantiating a :class:`.WebhookEndpoint`. - Args: - route (str) - The route for the endpoint. - type (WebhookType) - The type of the endpoint. - auth (str) - The auth for the endpoint. + .. code-block:: python - Returns: - :obj:`typing.Callable` [[ :obj:`typing.Callable` [..., :obj:`typing.Any` ]], :obj:`WebhookEndpoint` ]: - The actual decorator. + manager = topgg.WebhookManager() - :Example: - .. code-block:: python - import topgg + @topgg.endpoint('/dblwebhook', WebhookType.BOT, 'youshallnotpass') + async def on_vote(vote: topgg.BotVoteData): ... + + + manager.endpoint(on_vote) + + await manager.start(8080) + + :param route: The endpoint's route. + :type route: :py:class:`str` + :param type: The endpoint's type. + :type type: :class:`.WebhookType` + :param auth: The endpoint's password. + :type auth: :py:class:`str` - @topgg.endpoint("/dblwebhook", WebhookType.BOT, "youshallnotpass") - async def on_vote( - vote_data: topgg.BotVoteData, - # database here is an injected data - database: Database = topgg.data(Database), - ): - ... + :returns: The actual decorator. + :rtype: Callable[[Callable[..., Any]], :class:`.WebhookEndpoint`] """ def decorator(func: t.Callable[..., t.Any]) -> WebhookEndpoint: From 247eb22b3cef42fd00510a66d333d35eb0d90d88 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 9 Oct 2025 14:40:07 +0700 Subject: [PATCH 02/19] refactor: document the type of __slots__ --- topgg/autopost.py | 2 +- topgg/data.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/topgg/autopost.py b/topgg/autopost.py index 9be6ab6b..72ee608b 100644 --- a/topgg/autopost.py +++ b/topgg/autopost.py @@ -50,7 +50,7 @@ class AutoPoster: :type client: :class:`.DBLClient` """ - __slots__ = ( + __slots__: tuple[str, ...] = ( '_error', '_success', '_interval', diff --git a/topgg/data.py b/topgg/data.py index 4b17a6d3..fe11c796 100644 --- a/topgg/data.py +++ b/topgg/data.py @@ -58,7 +58,7 @@ def get_stats(bot: MyBot = topgg.data(MyBot)) -> topgg.StatsWrapper: class Data(t.Generic[T]): - __slots__ = ('type',) + __slots__: tuple[str, ...] = ('type',) def __init__(self, type_: t.Type[T]) -> None: self.type = type_ @@ -71,7 +71,7 @@ class DataContainerMixin: This is useful for injecting some data so that they're available as arguments in your functions. """ - __slots__ = ('_data',) + __slots__: tuple[str, ...] = ('_data',) def __init__(self) -> None: self._data = {type(self): self} From 35ade93526f617d5c14d838181a86ba9eb357963 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 9 Oct 2025 14:54:41 +0700 Subject: [PATCH 03/19] meta: remove license classifier --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 70e81ce8..16d6e712 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,6 @@ keywords = ["discord", "discord-bot", "topgg"] dependencies = ["aiohttp>=3.13.0"] classifiers = [ "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: MIT License", "Intended Audience :: Developers", "Natural Language :: English", "Operating System :: OS Independent", From f3d9e80a973754aa3af64805ebf56567bff77a4b Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 9 Oct 2025 15:05:45 +0700 Subject: [PATCH 04/19] fix: fix error message --- topgg/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/topgg/client.py b/topgg/client.py index bed29d03..ba8acf04 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -147,7 +147,7 @@ async def __request( body: Optional[dict] = None, ) -> dict: if self.is_closed: - raise errors.ClientStateException('DBLClient session is already closed.') + raise errors.ClientStateException('Client session is already closed.') if self.__current_ratelimit is not None: current_time = time() From 9c58852fb78f2ec495ceb40305f5ab489b235129 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 9 Oct 2025 15:08:17 +0700 Subject: [PATCH 05/19] meta: also prune examples directory --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index eff4806e..e29d9e20 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ prune .github prune .ruff_cache +prune examples exclude .gitignore exclude .readthedocs.yml exclude ruff.toml From 8a686b3473652cb0bd22c6faf10fedc3c0251718 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 9 Oct 2025 15:11:37 +0700 Subject: [PATCH 06/19] ci: drop support for Python 3.9, add support for Python 3.14 --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f637a26..1284d783 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ jobs: - uses: actions/checkout@v5 - uses: actions/setup-python@v6 with: - python-version: 3.13 + python-version: 3.14 - name: Install dependencies run: python3 -m pip install build twine - name: Build and publish diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c077d633..ffa990cb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ 3.9, '3.10', 3.11, 3.12, 3.13 ] + python-version: [ '3.10', 3.11, 3.12, 3.13, 3.14 ] steps: - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} From dbe3bf4fafd7805ad23339b73094c80194a0a10b Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 9 Oct 2025 15:15:55 +0700 Subject: [PATCH 07/19] fix: fix wrong type --- topgg/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/topgg/types.py b/topgg/types.py index a1c9990c..b3cae998 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -192,7 +192,7 @@ class BotData: donatebotguildid: int """This bot's donatebot setup server ID.""" - server_count: t.Optional[str] + server_count: t.Optional[int] """This bot's posted server count.""" review_score: float From 05859dbe0860624e443d37feec6cd4f858eafe8d Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 9 Oct 2025 15:21:28 +0700 Subject: [PATCH 08/19] meta: also prune docs directory --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index e29d9e20..921ba3b5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ prune .github prune .ruff_cache +prune docs prune examples exclude .gitignore exclude .readthedocs.yml From be043934c72c5f5b2a27413068041e2277d9bd71 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 20 Oct 2025 18:50:11 +0700 Subject: [PATCH 09/19] deps: bump aiohttp version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 16d6e712..e814a877 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.md" license = { text = "MIT" } authors = [{ name = "null8626" }, { name = "Top.gg" }] keywords = ["discord", "discord-bot", "topgg"] -dependencies = ["aiohttp>=3.13.0"] +dependencies = ["aiohttp>=3.13.1"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", From ffe3e250bf7cff9b6622151f8d70f1c6ffd14d0e Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 21 Oct 2025 12:54:14 +0700 Subject: [PATCH 10/19] fix: fix test CI --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ffa990cb..ca84c58c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,6 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install - run: python -m pip install .[dev] + run: python -m pip install . pytest - name: Test with pytest run: pytest From 0dd2f08bc688065bc5558ab27a753767d03cdd1f Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 21 Oct 2025 12:59:29 +0700 Subject: [PATCH 11/19] meta: update examples license docstring comments --- examples/discordpy_example/__main__.py | 45 ++++++++++--------- .../discordpy_example/callbacks/autopost.py | 45 ++++++++++--------- .../discordpy_example/callbacks/webhook.py | 44 +++++++++--------- examples/hikari_example/__main__.py | 45 ++++++++++--------- examples/hikari_example/callbacks/autopost.py | 45 ++++++++++--------- examples/hikari_example/callbacks/webhook.py | 44 +++++++++--------- examples/hikari_example/events/autopost.py | 45 ++++++++++--------- examples/hikari_example/events/webhook.py | 45 ++++++++++--------- 8 files changed, 190 insertions(+), 168 deletions(-) diff --git a/examples/discordpy_example/__main__.py b/examples/discordpy_example/__main__.py index f1c1f6dd..0550068e 100644 --- a/examples/discordpy_example/__main__.py +++ b/examples/discordpy_example/__main__.py @@ -1,24 +1,27 @@ -# The MIT License (MIT) - -# Copyright (c) 2021 Norizon - -# Permission is hereby granted, free of charge, to any person obtaining a -# copy of this software and associated documentation files (the "Software"), -# to deal in the Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, distribute, sublicense, -# and/or sell copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. +""" +The MIT License (MIT) + +Copyright (c) 2021 Norizon + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + import discord import topgg diff --git a/examples/discordpy_example/callbacks/autopost.py b/examples/discordpy_example/callbacks/autopost.py index e6592a6d..76da7d8b 100644 --- a/examples/discordpy_example/callbacks/autopost.py +++ b/examples/discordpy_example/callbacks/autopost.py @@ -1,24 +1,27 @@ -# The MIT License (MIT) - -# Copyright (c) 2021 Norizon - -# Permission is hereby granted, free of charge, to any person obtaining a -# copy of this software and associated documentation files (the "Software"), -# to deal in the Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, distribute, sublicense, -# and/or sell copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. +""" +The MIT License (MIT) + +Copyright (c) 2021 Norizon + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + import sys import discord diff --git a/examples/discordpy_example/callbacks/webhook.py b/examples/discordpy_example/callbacks/webhook.py index 358753c1..299327f4 100644 --- a/examples/discordpy_example/callbacks/webhook.py +++ b/examples/discordpy_example/callbacks/webhook.py @@ -1,24 +1,26 @@ -# The MIT License (MIT) - -# Copyright (c) 2021 Norizon - -# Permission is hereby granted, free of charge, to any person obtaining a -# copy of this software and associated documentation files (the "Software"), -# to deal in the Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, distribute, sublicense, -# and/or sell copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. +""" +The MIT License (MIT) + +Copyright (c) 2021 Norizon + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" # import discord diff --git a/examples/hikari_example/__main__.py b/examples/hikari_example/__main__.py index 0bef502f..b1111190 100644 --- a/examples/hikari_example/__main__.py +++ b/examples/hikari_example/__main__.py @@ -1,24 +1,27 @@ -# The MIT License (MIT) - -# Copyright (c) 2021 Norizon - -# Permission is hereby granted, free of charge, to any person obtaining a -# copy of this software and associated documentation files (the "Software"), -# to deal in the Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, distribute, sublicense, -# and/or sell copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. +""" +The MIT License (MIT) + +Copyright (c) 2021 Norizon + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + import hikari import topgg diff --git a/examples/hikari_example/callbacks/autopost.py b/examples/hikari_example/callbacks/autopost.py index 3ac467b3..bdec5337 100644 --- a/examples/hikari_example/callbacks/autopost.py +++ b/examples/hikari_example/callbacks/autopost.py @@ -1,24 +1,27 @@ -# The MIT License (MIT) - -# Copyright (c) 2021 Norizon - -# Permission is hereby granted, free of charge, to any person obtaining a -# copy of this software and associated documentation files (the "Software"), -# to deal in the Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, distribute, sublicense, -# and/or sell copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. +""" +The MIT License (MIT) + +Copyright (c) 2021 Norizon + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + import logging import hikari diff --git a/examples/hikari_example/callbacks/webhook.py b/examples/hikari_example/callbacks/webhook.py index 50c53a73..3be95a5c 100644 --- a/examples/hikari_example/callbacks/webhook.py +++ b/examples/hikari_example/callbacks/webhook.py @@ -1,24 +1,26 @@ -# The MIT License (MIT) - -# Copyright (c) 2021 Norizon - -# Permission is hereby granted, free of charge, to any person obtaining a -# copy of this software and associated documentation files (the "Software"), -# to deal in the Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, distribute, sublicense, -# and/or sell copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. +""" +The MIT License (MIT) + +Copyright (c) 2021 Norizon + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" import logging diff --git a/examples/hikari_example/events/autopost.py b/examples/hikari_example/events/autopost.py index ddc7aa22..02e9967d 100644 --- a/examples/hikari_example/events/autopost.py +++ b/examples/hikari_example/events/autopost.py @@ -1,24 +1,27 @@ -# The MIT License (MIT) - -# Copyright (c) 2021 Norizon - -# Permission is hereby granted, free of charge, to any person obtaining a -# copy of this software and associated documentation files (the "Software"), -# to deal in the Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, distribute, sublicense, -# and/or sell copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. +""" +The MIT License (MIT) + +Copyright (c) 2021 Norizon + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + import attr import hikari diff --git a/examples/hikari_example/events/webhook.py b/examples/hikari_example/events/webhook.py index b9b6d21f..c3dfc729 100644 --- a/examples/hikari_example/events/webhook.py +++ b/examples/hikari_example/events/webhook.py @@ -1,24 +1,27 @@ -# The MIT License (MIT) - -# Copyright (c) 2021 Norizon - -# Permission is hereby granted, free of charge, to any person obtaining a -# copy of this software and associated documentation files (the "Software"), -# to deal in the Software without restriction, including without limitation -# the rights to use, copy, modify, merge, publish, distribute, sublicense, -# and/or sell copies of the Software, and to permit persons to whom the -# Software is furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. +""" +The MIT License (MIT) + +Copyright (c) 2021 Norizon + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + import attr import hikari From 31c3e70c6d47e593d400429c80156a1d17babcc3 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 21 Oct 2025 15:09:05 +0700 Subject: [PATCH 12/19] fix: fix tests not working --- .github/workflows/test.yml | 2 +- .gitignore | 7 +- docs/api/types.rst | 4 - docs/conf.py | 64 +++--- examples/discordpy_example/__main__.py | 4 +- .../discordpy_example/callbacks/autopost.py | 4 +- .../discordpy_example/callbacks/webhook.py | 4 +- examples/hikari_example/__main__.py | 4 +- examples/hikari_example/callbacks/autopost.py | 7 +- examples/hikari_example/callbacks/webhook.py | 7 +- tests/test_autopost.py | 190 ++++++++--------- tests/test_client.py | 20 +- tests/test_data_container.py | 19 +- tests/test_ratelimiter.py | 56 ++--- tests/test_type.py | 199 ++++++------------ tests/test_webhook.py | 160 +++++++------- topgg/autopost.py | 10 +- topgg/client.py | 4 + topgg/types.py | 31 +-- topgg/webhook.py | 23 +- 20 files changed, 391 insertions(+), 428 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ca84c58c..784f0b5e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,6 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install - run: python -m pip install . pytest + run: python -m pip install . pytest mock pytest-mock pytest-asyncio - name: Test with pytest run: pytest diff --git a/.gitignore b/.gitignore index f222ed9d..250f982c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ **/__pycache__/ -topggpy.egg-info/ +.ruff_cache/ +.vscode/ +build/ docs/_build/ dist/ -.ruff_cache/ -.vscode/ \ No newline at end of file +topggpy.egg-info/ \ No newline at end of file diff --git a/docs/api/types.rst b/docs/api/types.rst index a6a70f84..44c11251 100644 --- a/docs/api/types.rst +++ b/docs/api/types.rst @@ -5,10 +5,6 @@ Models API Reference .. automodule:: topgg.types :members: -.. autoclass:: topgg.types.DataDict - :members: - :inherited-members: - .. autoclass:: topgg.types.BotData :members: :show-inheritance: diff --git a/docs/conf.py b/docs/conf.py index 2d368576..27fd7ee8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ import alabaster -sys.path.insert(0, os.path.abspath("../")) +sys.path.insert(0, os.path.abspath('../')) from topgg import __version__ as version # import re @@ -39,44 +39,44 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.viewcode", - "sphinx.ext.autosectionlabel", - "sphinx.ext.extlinks", - "sphinx.ext.intersphinx", - "sphinx.ext.napoleon", + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', + 'sphinx.ext.autosectionlabel', + 'sphinx.ext.extlinks', + 'sphinx.ext.intersphinx', + 'sphinx.ext.napoleon', ] -autodoc_member_order = "groupwise" +autodoc_member_order = 'groupwise' extlinks = { - "issue": ("https://github.com/top-gg/python-sdk/issues/%s", "GH-"), + 'issue': ('https://github.com/top-gg/python-sdk/issues/%s', 'GH-'), } intersphinx_mapping = { - "py": ("https://docs.python.org/3", None), - "discord": ("https://discordpy.readthedocs.io/en/latest/", None), - "aiohttp": ("https://docs.aiohttp.org/en/stable/", None), + 'py': ('https://docs.python.org/3', None), + 'discord': ('https://discordpy.readthedocs.io/en/latest/', None), + 'aiohttp': ('https://docs.aiohttp.org/en/stable/', None), } -releases_github_path = "top-gg/python-sdk" +releases_github_path = 'top-gg/python-sdk' # Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] +templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = ".rst" +source_suffix = '.rst' # The master toctree document. -master_doc = "index" +master_doc = 'index' # General information about the project. -project = "topggpy" -copyright = "2021, Assanali Mukhanov" -author = "Assanali Mukhanov" +project = 'topggpy' +copyright = '2021, Assanali Mukhanov' +author = 'Assanali Mukhanov' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -99,25 +99,25 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ["_build"] +exclude_patterns = ['_build'] # -- Options for HTML output ---------------------------------------------- -html_theme_options = {"navigation_depth": 2} +html_theme_options = {'navigation_depth': 2} html_theme_path = [alabaster.get_path()] # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = "insegel" +html_theme = 'insegel' # The name of an image file (relative to this directory) to place at the top # of the sidebar. -html_logo = "topgg.svg" +html_logo = 'topgg.svg' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] +html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied @@ -183,7 +183,7 @@ # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. -htmlhelp_basename = "topggpydoc" +htmlhelp_basename = 'topggpydoc' # -- Options for LaTeX output --------------------------------------------- @@ -206,14 +206,14 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, "topggpy.tex", "topggpy Documentation", "Assanali Mukhanov", "manual"), + (master_doc, 'topggpy.tex', 'topggpy Documentation', 'Assanali Mukhanov', 'manual'), ] # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [(master_doc, "topggpy", "topggpy Documentation", [author], 1)] +man_pages = [(master_doc, 'topggpy', 'topggpy Documentation', [author], 1)] # -- Options for Texinfo output ------------------------------------------- @@ -223,11 +223,11 @@ texinfo_documents = [ ( master_doc, - "topggpy", - "topggpy Documentation", + 'topggpy', + 'topggpy Documentation', author, - "topggpy", - "One line description of project.", - "Miscellaneous", + 'topggpy', + 'One line description of project.', + 'Miscellaneous', ), ] diff --git a/examples/discordpy_example/__main__.py b/examples/discordpy_example/__main__.py index 0550068e..5e40e092 100644 --- a/examples/discordpy_example/__main__.py +++ b/examples/discordpy_example/__main__.py @@ -30,7 +30,7 @@ client = discord.Client() webhook_manager = topgg.WebhookManager().set_data(client).endpoint(webhook.endpoint) -dblclient = topgg.DBLClient("TOPGG_TOKEN").set_data(client) +dblclient = topgg.DBLClient('TOPGG_TOKEN').set_data(client) autoposter: topgg.AutoPoster = ( dblclient.autopost() .on_success(autopost.on_autopost_success) @@ -58,4 +58,4 @@ async def on_ready(): # TODO: find a way to figure out when the bot shuts down # so we can close the client and the webhook manager -client.run("TOKEN") +client.run('TOKEN') diff --git a/examples/discordpy_example/callbacks/autopost.py b/examples/discordpy_example/callbacks/autopost.py index 76da7d8b..7808f3a9 100644 --- a/examples/discordpy_example/callbacks/autopost.py +++ b/examples/discordpy_example/callbacks/autopost.py @@ -35,7 +35,7 @@ def on_autopost_success( # client: discord.Client = topgg.data(discord.Client) ): # will be called whenever it successfully posting - print("Successfully posted!") + print('Successfully posted!') # do whatever with client # you can dispatch your own event for more callbacks @@ -48,7 +48,7 @@ def on_autopost_error( # client: discord.Client = topgg.data(discord.Client), ): # will be called whenever it failed posting - print("Failed to post:", exception, file=sys.stderr) + print('Failed to post:', exception, file=sys.stderr) # do whatever with client # you can dispatch your own event for more callbacks diff --git a/examples/discordpy_example/callbacks/webhook.py b/examples/discordpy_example/callbacks/webhook.py index 299327f4..6d710b20 100644 --- a/examples/discordpy_example/callbacks/webhook.py +++ b/examples/discordpy_example/callbacks/webhook.py @@ -28,14 +28,14 @@ # this can be async too! -@topgg.endpoint("/dblwebhook", topgg.WebhookType.BOT, "youshallnotpass") +@topgg.endpoint('/dblwebhook', topgg.WebhookType.BOT, 'youshallnotpass') def endpoint( vote_data: topgg.BotVoteData, # uncomment this if you want to get access to client # client: discord.Client = topgg.data(discord.Client), ): # this function will be called whenever someone votes for your bot. - print("Received a vote!", vote_data) + print('Received a vote!', vote_data) # do anything with client here # client.dispatch("dbl_vote", vote_data) diff --git a/examples/hikari_example/__main__.py b/examples/hikari_example/__main__.py index b1111190..26c3b240 100644 --- a/examples/hikari_example/__main__.py +++ b/examples/hikari_example/__main__.py @@ -28,9 +28,9 @@ from .callbacks import autopost, webhook -app = hikari.GatewayBot("TOKEN") +app = hikari.GatewayBot('TOKEN') webhook_manager = topgg.WebhookManager().set_data(app).endpoint(webhook.endpoint) -dblclient = topgg.DBLClient("TOPGG_TOKEN").set_data(app) +dblclient = topgg.DBLClient('TOPGG_TOKEN').set_data(app) autoposter: topgg.AutoPoster = ( dblclient.autopost() .on_success(autopost.on_autopost_success) diff --git a/examples/hikari_example/callbacks/autopost.py b/examples/hikari_example/callbacks/autopost.py index bdec5337..a4d699bd 100644 --- a/examples/hikari_example/callbacks/autopost.py +++ b/examples/hikari_example/callbacks/autopost.py @@ -30,7 +30,8 @@ # from ..events.autopost import AutoPostErrorEvent, AutoPostSuccessEvent -_LOGGER = logging.getLogger("callbacks.autopost") +_LOGGER = logging.getLogger('callbacks.autopost') + # these functions can be async too! def on_autopost_success( @@ -38,7 +39,7 @@ def on_autopost_success( # app: hikari.GatewayBot = topgg.data(hikari.GatewayBot), ): # will be called whenever it successfully posting - _LOGGER.info("Successfully posted!") + _LOGGER.info('Successfully posted!') # do whatever with app # you can dispatch your own event for more callbacks @@ -51,7 +52,7 @@ def on_autopost_error( # app: hikari.GatewayBot = topgg.data(hikari.GatewayBot), ): # will be called whenever it failed posting - _LOGGER.error("Failed to post...", exc_info=exception) + _LOGGER.error('Failed to post...', exc_info=exception) # do whatever with app # you can dispatch your own event for more callbacks diff --git a/examples/hikari_example/callbacks/webhook.py b/examples/hikari_example/callbacks/webhook.py index 3be95a5c..0441c3ee 100644 --- a/examples/hikari_example/callbacks/webhook.py +++ b/examples/hikari_example/callbacks/webhook.py @@ -31,16 +31,17 @@ # from ..events import BotUpvoteEvent -_LOGGER = logging.getLogger("callbacks.webhook") +_LOGGER = logging.getLogger('callbacks.webhook') + # this can be async too! -@topgg.endpoint("/dblwebhook", topgg.WebhookType.BOT, "youshallnotpass") +@topgg.endpoint('/dblwebhook', topgg.WebhookType.BOT, 'youshallnotpass') async def endpoint( vote_data: topgg.BotVoteData, # uncomment this if you want to get access to app # app: hikari.GatewayBot = topgg.data(hikari.GatewayBot), ): # this function will be called whenever someone votes for your bot. - _LOGGER.info("Receives a vote! %s", vote_data) + _LOGGER.info('Receives a vote! %s', vote_data) # do anything with app here. # app.dispatch(BotUpvoteEvent(app=app, data=vote_data)) diff --git a/tests/test_autopost.py b/tests/test_autopost.py index b83a0cc0..14e92c74 100644 --- a/tests/test_autopost.py +++ b/tests/test_autopost.py @@ -1,95 +1,95 @@ -import datetime - -import mock -import pytest -from aiohttp import ClientSession -from pytest_mock import MockerFixture - -from topgg import DBLClient -from topgg.autopost import AutoPoster -from topgg.errors import HTTPException, TopGGException - - -MOCK_TOKEN = ".eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=." - - -@pytest.fixture -def session() -> ClientSession: - return mock.Mock(ClientSession) - - -@pytest.fixture -def autopost(session: ClientSession) -> AutoPoster: - return AutoPoster(DBLClient(MOCK_TOKEN, session=session)) - - -@pytest.mark.asyncio -async def test_AutoPoster_breaks_autopost_loop_on_401( - mocker: MockerFixture, session: ClientSession -) -> None: - response = mock.Mock("reason, status") - response.reason = "Unauthorized" - response.status = 401 - - mocker.patch( - "topgg.DBLClient.post_guild_count", side_effect=HTTPException(response, {}) - ) - - callback = mock.Mock() - autopost = DBLClient(MOCK_TOKEN, session=session).autopost().stats(callback) - assert isinstance(autopost, AutoPoster) - assert not isinstance(autopost.stats()(callback), AutoPoster) - - with pytest.raises(HTTPException): - await autopost.start() - - callback.assert_called_once() - assert not autopost.is_running - - -@pytest.mark.asyncio -async def test_AutoPoster_raises_missing_stats(autopost: AutoPoster) -> None: - with pytest.raises( - TopGGException, match="you must provide a callback that returns the stats." - ): - await autopost.start() - - -@pytest.mark.asyncio -async def test_AutoPoster_raises_already_running(autopost: AutoPoster) -> None: - autopost.stats(mock.Mock()).start() - with pytest.raises(TopGGException, match="the autopost is already running."): - await autopost.start() - - -@pytest.mark.asyncio -async def test_AutoPoster_interval_too_short(autopost: AutoPoster) -> None: - with pytest.raises(ValueError, match="interval must be greated than 900 seconds."): - autopost.set_interval(50) - - -@pytest.mark.asyncio -async def test_AutoPoster_error_callback( - mocker: MockerFixture, autopost: AutoPoster -) -> None: - error_callback = mock.Mock() - response = mock.Mock("reason, status") - response.reason = "Internal Server Error" - response.status = 500 - side_effect = HTTPException(response, {}) - - mocker.patch("topgg.DBLClient.post_guild_count", side_effect=side_effect) - task = autopost.on_error(error_callback).stats(mock.Mock()).start() - autopost.stop() - await task - error_callback.assert_called_once_with(side_effect) - - -def test_AutoPoster_interval(autopost: AutoPoster): - assert autopost.interval == 900 - autopost.set_interval(datetime.timedelta(hours=1)) - assert autopost.interval == 3600 - autopost.interval = datetime.timedelta(hours=2) - assert autopost.interval == 7200 - autopost.interval = 3600 - assert autopost.interval == 3600 +import datetime + +import mock +import pytest +from aiohttp import ClientSession +from pytest_mock import MockerFixture + +from topgg import DBLClient +from topgg.autopost import AutoPoster +from topgg.errors import HTTPException, TopGGException + + +MOCK_TOKEN = '.eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=.' + + +@pytest.fixture +def session() -> ClientSession: + return mock.Mock(ClientSession) + + +@pytest.fixture +def autopost(session: ClientSession) -> AutoPoster: + return AutoPoster(DBLClient(MOCK_TOKEN, session=session)) + + +# @pytest.mark.asyncio +# async def test_AutoPoster_breaks_autopost_loop_on_401( +# mocker: MockerFixture, session: ClientSession +# ) -> None: +# response = mock.Mock("reason, status") +# response.reason = "Unauthorized" +# response.status = 401 +# +# mocker.patch( +# "topgg.DBLClient.post_guild_count", side_effect=HTTPException(response, {}) +# ) +# +# callback = mock.Mock() +# autopost = DBLClient(MOCK_TOKEN, session=session).autopost().stats(callback) +# assert isinstance(autopost, AutoPoster) +# assert not isinstance(autopost.stats()(callback), AutoPoster) +# +# with pytest.raises(HTTPException): +# await autopost.start() +# +# callback.assert_called_once() +# assert not autopost.is_running + + +@pytest.mark.asyncio +async def test_AutoPoster_raises_missing_stats(autopost: AutoPoster) -> None: + with pytest.raises( + TopGGException, match='You must provide a callback that returns the stats.' + ): + await autopost.start() + + +@pytest.mark.asyncio +async def test_AutoPoster_raises_already_running(autopost: AutoPoster) -> None: + autopost.stats(mock.Mock()).start() + with pytest.raises(TopGGException, match='The autoposter is already running.'): + await autopost.start() + + +@pytest.mark.asyncio +async def test_AutoPoster_interval_too_short(autopost: AutoPoster) -> None: + with pytest.raises(ValueError, match='interval must be greater than 900 seconds.'): + autopost.set_interval(50) + + +@pytest.mark.asyncio +async def test_AutoPoster_error_callback( + mocker: MockerFixture, autopost: AutoPoster +) -> None: + error_callback = mock.Mock() + response = mock.Mock('reason, status') + response.reason = 'Internal Server Error' + response.status = 500 + side_effect = HTTPException(response, {}) + + mocker.patch('topgg.DBLClient.post_guild_count', side_effect=side_effect) + task = autopost.on_error(error_callback).stats(mock.Mock()).start() + autopost.stop() + await task + error_callback.assert_called_once_with(side_effect) + + +def test_AutoPoster_interval(autopost: AutoPoster): + assert autopost.interval == 900 + autopost.set_interval(datetime.timedelta(hours=1)) + assert autopost.interval == 3600 + autopost.interval = datetime.timedelta(hours=2) + assert autopost.interval == 7200 + autopost.interval = 3600 + assert autopost.interval == 3600 diff --git a/tests/test_client.py b/tests/test_client.py index f0a9c456..67f032c2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,20 +4,20 @@ import topgg -MOCK_TOKEN = ".eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=." +MOCK_TOKEN = '.eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=.' @pytest.mark.asyncio async def test_DBLClient_post_guild_count_with_no_args(): client = topgg.DBLClient(MOCK_TOKEN) - with pytest.raises(TypeError, match="stats or guild_count must be provided."): + with pytest.raises(ValueError, match='Got an invalid server count. Got None.'): await client.post_guild_count() @pytest.mark.asyncio async def test_DBLClient_get_weekend_status(monkeypatch): client = topgg.DBLClient(MOCK_TOKEN) - monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock()) + monkeypatch.setattr('topgg.DBLClient._DBLClient__request', mock.AsyncMock()) await client.get_weekend_status() client._DBLClient__request.assert_called_once() @@ -25,7 +25,7 @@ async def test_DBLClient_get_weekend_status(monkeypatch): @pytest.mark.asyncio async def test_DBLClient_post_guild_count(monkeypatch): client = topgg.DBLClient(MOCK_TOKEN) - monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock()) + monkeypatch.setattr('topgg.DBLClient._DBLClient__request', mock.AsyncMock()) await client.post_guild_count(guild_count=123) client._DBLClient__request.assert_called_once() @@ -33,7 +33,9 @@ async def test_DBLClient_post_guild_count(monkeypatch): @pytest.mark.asyncio async def test_DBLClient_get_guild_count(monkeypatch): client = topgg.DBLClient(MOCK_TOKEN) - monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock(return_value={})) + monkeypatch.setattr( + 'topgg.DBLClient._DBLClient__request', mock.AsyncMock(return_value={}) + ) await client.get_guild_count() client._DBLClient__request.assert_called_once() @@ -41,7 +43,9 @@ async def test_DBLClient_get_guild_count(monkeypatch): @pytest.mark.asyncio async def test_DBLClient_get_bot_votes(monkeypatch): client = topgg.DBLClient(MOCK_TOKEN) - monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock(return_value=[])) + monkeypatch.setattr( + 'topgg.DBLClient._DBLClient__request', mock.AsyncMock(return_value=[]) + ) await client.get_bot_votes() client._DBLClient__request.assert_called_once() @@ -49,6 +53,8 @@ async def test_DBLClient_get_bot_votes(monkeypatch): @pytest.mark.asyncio async def test_DBLClient_get_user_vote(monkeypatch): client = topgg.DBLClient(MOCK_TOKEN) - monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock(return_value={"voted": 1})) + monkeypatch.setattr( + 'topgg.DBLClient._DBLClient__request', mock.AsyncMock(return_value={'voted': 1}) + ) await client.get_user_vote(1234) client._DBLClient__request.assert_called_once() diff --git a/tests/test_data_container.py b/tests/test_data_container.py index 978574fb..75230fdc 100644 --- a/tests/test_data_container.py +++ b/tests/test_data_container.py @@ -7,26 +7,23 @@ @pytest.fixture def data_container() -> DataContainerMixin: dc = DataContainerMixin() - dc.set_data("TEXT") + dc.set_data('TEXT') dc.set_data(200) - dc.set_data({"a": "b"}) + dc.set_data({'a': 'b'}) return dc async def _async_callback( text: str = data(str), number: int = data(int), mapping: dict = data(dict) -): - ... +): ... def _sync_callback( text: str = data(str), number: int = data(int), mapping: dict = data(dict) -): - ... +): ... -def _invalid_callback(number: float = data(float)): - ... +def _invalid_callback(number: float = data(float)): ... @pytest.mark.asyncio @@ -43,9 +40,9 @@ def test_data_container_raises_data_already_exists(data_container: DataContainer with pytest.raises( TopGGException, match=" already exists. If you wish to override it, " - "pass True into the override parameter.", + 'pass True into the override parameter.', ): - data_container.set_data("TEST") + data_container.set_data('TEST') @pytest.mark.asyncio @@ -55,6 +52,6 @@ async def test_data_container_raises_key_error(data_container: DataContainerMixi def test_data_container_get_data(data_container: DataContainerMixin): - assert data_container.get_data(str) == "TEXT" + assert data_container.get_data(str) == 'TEXT' assert data_container.get_data(float) is None assert isinstance(data_container.get_data(set, set()), set) diff --git a/tests/test_ratelimiter.py b/tests/test_ratelimiter.py index 998a7357..53692fe4 100644 --- a/tests/test_ratelimiter.py +++ b/tests/test_ratelimiter.py @@ -1,28 +1,28 @@ -import pytest - -from topgg.ratelimiter import Ratelimiter - -n = period = 10 - - -@pytest.fixture -def limiter() -> Ratelimiter: - return Ratelimiter(max_calls=n, period=period) - - -@pytest.mark.asyncio -async def test_AsyncRateLimiter_calls(limiter: Ratelimiter) -> None: - for _ in range(n): - async with limiter: - pass - - assert len(limiter._Ratelimiter__calls) == limiter._Ratelimiter__max_calls == n - - -@pytest.mark.asyncio -async def test_AsyncRateLimiter_timespan_property(limiter: Ratelimiter) -> None: - for _ in range(n): - async with limiter: - pass - - assert limiter._timespan < period +import pytest + +from topgg.ratelimiter import Ratelimiter + +n = period = 10 + + +@pytest.fixture +def limiter() -> Ratelimiter: + return Ratelimiter(max_calls=n, period=period) + + +@pytest.mark.asyncio +async def test_AsyncRateLimiter_calls(limiter: Ratelimiter) -> None: + for _ in range(n): + async with limiter: + pass + + assert len(limiter._Ratelimiter__calls) == limiter._Ratelimiter__max_calls == n + + +@pytest.mark.asyncio +async def test_AsyncRateLimiter_timespan_property(limiter: Ratelimiter) -> None: + for _ in range(n): + async with limiter: + pass + + assert limiter._timespan < period diff --git a/tests/test_type.py b/tests/test_type.py index caec363c..1aab09fe 100644 --- a/tests/test_type.py +++ b/tests/test_type.py @@ -3,200 +3,131 @@ from topgg import types d: dict = { - "defAvatar": "6debd47ed13483642cf09e832ed0bc1b", - "invite": "", - "website": "https://top.gg", - "support": "KYZsaFb", - "github": "https://github.com/top-gg/Luca", - "longdesc": "Luca only works in the **Discord Bot List** server. \nPrepend commands with the prefix `-` or " - "`@Luca#1375`. \n**Please refrain from using these commands in non testing channels.**\n- `botinfo " - "@bot` Shows bot info, title redirects to site listing.\n- `bots @user`* Shows all bots of that user, " - "includes bots in the queue.\n- `owner / -owners @bot`* Shows all owners of that bot.\n- `prefix " - "@bot`* Shows the prefix of that bot.\n* Mobile friendly version exists. Just add `noembed` to the " - "end of the command.\n", - "shortdesc": "Luca is a bot for managing and informing members of the server", - "prefix": "- or @Luca#1375", - "lib": None, - "clientid": "264811613708746752", - "avatar": "7edcc4c6fbb0b23762455ca139f0e1c9", - "id": "264811613708746752", - "discriminator": "1375", - "username": "Luca", - "date": "2017-04-26T18:08:17.125Z", - "server_count": 2, - "guilds": ["417723229721853963", "264445053596991498"], - "shards": [], - "monthlyPoints": 19, - "points": 397, - "certifiedBot": False, - "owners": ["129908908096487424"], - "tags": ["Moderation", "Role Management", "Logging"], - "donatebotguildid": "", + 'invite': 'https://top.gg/discord', + 'support': 'https://discord.gg/dbl', + 'github': 'https://github.com/top-gg', + 'longdesc': "A bot to grant API access to our Library Developers on the Top.gg site without them needing to submit a bot to pass verification just to be able to access the API.\n\nThis is not a real bot, so if you happen to find this page, do not try to invite it. It will not work.\n\nAccess to this bot's team can be requested by contacting a Community Manager in [our Discord server](https://top.gg/discord).", + 'shortdesc': 'API access for Top.gg Library Developers', + 'prefix': '/', + 'lib': '', + 'clientid': '1026525568344264724', + 'avatar': 'https://cdn.discordapp.com/avatars/1026525568344264724/cd70e62e41f691f1c05c8455d8c31e23.png', + 'id': '1026525568344264724', + 'username': 'Top.gg Lib Dev API Access', + 'date': '2022-10-03T16:08:55.292Z', + 'server_count': 2, + 'shard_count': 0, + 'guilds': [], + 'shards': [], + 'monthlyPoints': 2, + 'points': 28, + 'certifiedBot': False, + 'owners': ['121919449996460033'], + 'tags': ['api', 'library', 'topgg'], + 'reviews': {'averageScore': 5, 'count': 2}, } -query_dict = {"qwe": "1", "rty": "2", "uio": "3"} +query_dict = {'qwe': '1', 'rty': '2', 'uio': '3'} vote_data_dict = { - "type": "test", - "query": "?" + "&".join(f"{k}={v}" for k, v in query_dict.items()), - "user": "1", + 'type': 'test', + 'query': '?' + '&'.join(f'{k}={v}' for k, v in query_dict.items()), + 'user': '1', } bot_vote_dict = { - "bot": "2", - "user": "3", - "type": "test", - "query": "?" + "&".join(f"{k}={v}" for k, v in query_dict.items()), + 'bot': '2', + 'user': '3', + 'type': 'test', + 'query': '?' + '&'.join(f'{k}={v}' for k, v in query_dict.items()), + 'isWeekend': False, } server_vote_dict = { - "guild": "4", - "user": "5", - "type": "upvote", - "query": "?" + "&".join(f"{k}={v}" for k, v in query_dict.items()), + 'guild': '4', + 'user': '5', + 'type': 'upvote', + 'query': '?' + '&'.join(f'{k}={v}' for k, v in query_dict.items()), } -user_data_dict = { - "discriminator": "0001", - "avatar": "a_1241439d430def25c100dd28add2d42f", - "id": "140862798832861184", - "username": "Xetera", - "defAvatar": "322c936a8c8be1b803cd94861bdfa868", - "admin": True, - "webMod": True, - "mod": True, - "certifiedDev": False, - "supporter": False, - "social": {}, -} - -bot_stats_dict = {"shards": [1, 5, 8]} - - -@pytest.fixture -def data_dict() -> types.DataDict: - return types.DataDict(**d) +bot_stats_dict = {'server_count': 2, 'shards': [], 'shard_count': 0} @pytest.fixture def bot_data() -> types.BotData: - return types.BotData(**d) - - -@pytest.fixture -def user_data() -> types.UserData: - return types.UserData(**user_data_dict) + return types.BotData(d) @pytest.fixture def widget_options() -> types.WidgetOptions: - return types.WidgetOptions(id=int(d["id"])) + return types.WidgetOptions( + id=int(d['id']), + project_type=types.WidgetProjectType.DISCORD_BOT, + type=types.WidgetType.LARGE, + ) @pytest.fixture def vote_data() -> types.VoteDataDict: - return types.VoteDataDict(**vote_data_dict) + return types.VoteDataDict(vote_data_dict) @pytest.fixture def bot_vote_data() -> types.BotVoteData: - return types.BotVoteData(**bot_vote_dict) + return types.BotVoteData(bot_vote_dict) @pytest.fixture def server_vote_data() -> types.GuildVoteData: - return types.GuildVoteData(**server_vote_dict) + return types.GuildVoteData(server_vote_dict) @pytest.fixture def bot_stats_data() -> types.BotStatsData: - return types.BotStatsData(**bot_stats_dict) - - -def test_data_dict_fields(data_dict: types.DataDict) -> None: - for attr in data_dict: - if "id" in attr.lower(): - assert isinstance(data_dict[attr], int) or data_dict[attr] is None - assert data_dict.get(attr) == data_dict[attr] == getattr(data_dict, attr) + return types.BotStatsData(bot_stats_dict) def test_bot_data_fields(bot_data: types.BotData) -> None: bot_data.github = "I'm a GitHub link!" - bot_data.support = "Support has arrived!" + bot_data.support = 'Support has arrived!' - for attr in bot_data: - if "id" in attr.lower(): - assert isinstance(bot_data[attr], int) or bot_data[attr] is None - elif attr in ("owners", "guilds"): - for item in bot_data[attr]: - assert isinstance(item, int) - assert bot_data.get(attr) == bot_data[attr] == getattr(bot_data, attr) + for attr in bot_data.__slots__: + if 'id' in attr.lower(): + value = getattr(bot_data, attr) - -def test_widget_options_fields(widget_options: types.WidgetOptions) -> None: - assert widget_options["colors"] == widget_options["colours"] - - widget_options.colours = {"background": 0} - widget_options["colours"]["text"] = 255 - assert widget_options.colours == widget_options["colors"] - - for attr in widget_options: - if "id" in attr.lower(): - assert isinstance(widget_options[attr], int) or widget_options[attr] is None - assert ( - widget_options.get(attr) - == widget_options[attr] - == widget_options[attr] - == getattr(widget_options, attr) - ) + assert isinstance(value, int) or value is None + elif attr in ('owners', 'guilds'): + for item in getattr(bot_data, attr): + assert isinstance(item, int) def test_vote_data_fields(vote_data: types.VoteDataDict) -> None: assert isinstance(vote_data.query, dict) - vote_data.type = "upvote" - - for attr in vote_data: - assert getattr(vote_data, attr) == vote_data.get(attr) == vote_data[attr] + vote_data.type = 'upvote' def test_bot_vote_data_fields(bot_vote_data: types.BotVoteData) -> None: assert isinstance(bot_vote_data.query, dict) - bot_vote_data.type = "upvote" + bot_vote_data.type = 'upvote' - assert isinstance(bot_vote_data["bot"], int) - for attr in bot_vote_data: - assert ( - getattr(bot_vote_data, attr) - == bot_vote_data.get(attr) - == bot_vote_data[attr] - ) + assert isinstance(bot_vote_data.bot, int) def test_server_vote_data_fields(server_vote_data: types.BotVoteData) -> None: assert isinstance(server_vote_data.query, dict) - server_vote_data.type = "upvote" + server_vote_data.type = 'upvote' - assert isinstance(server_vote_data["guild"], int) - for attr in server_vote_data: - assert ( - getattr(server_vote_data, attr) - == server_vote_data.get(attr) - == server_vote_data[attr] - ) + assert isinstance(server_vote_data.guild, int) def test_bot_stats_data_attrs(bot_stats_data: types.BotStatsData) -> None: - for count in ("server_count", "shard_count"): - assert isinstance(bot_stats_data[count], int) or bot_stats_data[count] is None + for count in ('server_count', 'shard_count'): + value = getattr(bot_stats_data, count) + + assert isinstance(value, int) or value is None + assert isinstance(bot_stats_data.shards, list) + if bot_stats_data.shards: for shard in bot_stats_data.shards: assert isinstance(shard, int) - - -def test_user_data_attrs(user_data: types.UserData) -> None: - assert isinstance(user_data.social, types.SocialData) - for attr in user_data: - if "id" in attr.lower(): - assert isinstance(user_data[attr], int) or user_data[attr] is None - assert user_data[attr] == getattr(user_data, attr) == user_data.get(attr) diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 8ef3c71d..62e86cbb 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -1,80 +1,80 @@ -import typing as t - -import aiohttp -import mock -import pytest - -from topgg import WebhookManager, WebhookType -from topgg.errors import TopGGException - -auth = "youshallnotpass" - - -@pytest.fixture -def webhook_manager() -> WebhookManager: - return ( - WebhookManager() - .endpoint() - .type(WebhookType.BOT) - .auth(auth) - .route("/dbl") - .callback(print) - .add_to_manager() - .endpoint() - .type(WebhookType.GUILD) - .auth(auth) - .route("/dsl") - .callback(print) - .add_to_manager() - ) - - -def test_WebhookManager_routes(webhook_manager: WebhookManager) -> None: - assert len(webhook_manager.app.router.routes()) == 2 - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "headers, result, state", - [({"authorization": auth}, 200, True), ({}, 401, False)], -) -async def test_WebhookManager_validates_auth( - webhook_manager: WebhookManager, headers: t.Dict[str, str], result: int, state: bool -) -> None: - await webhook_manager.start(5000) - - try: - for path in ("dbl", "dsl"): - async with aiohttp.request( - "POST", f"http://localhost:5000/{path}", headers=headers, json={} - ) as r: - assert r.status == result - finally: - await webhook_manager.close() - assert not webhook_manager.is_running - - -def test_WebhookEndpoint_callback_unset(webhook_manager: WebhookManager): - with pytest.raises( - TopGGException, - match="endpoint missing callback.", - ): - webhook_manager.endpoint().add_to_manager() - - -def test_WebhookEndpoint_route_unset(webhook_manager: WebhookManager): - with pytest.raises( - TopGGException, - match="endpoint missing type.", - ): - webhook_manager.endpoint().callback(mock.Mock()).add_to_manager() - - -def test_WebhookEndpoint_type_unset(webhook_manager: WebhookManager): - with pytest.raises( - TopGGException, - match="endpoint missing route.", - ): - webhook_manager.endpoint().callback(mock.Mock()).type( - WebhookType.BOT - ).add_to_manager() +import typing as t + +import aiohttp +import mock +import pytest + +from topgg import WebhookManager, WebhookType +from topgg.errors import TopGGException + +auth = 'youshallnotpass' + + +@pytest.fixture +def webhook_manager() -> WebhookManager: + return ( + WebhookManager() + .endpoint() + .type(WebhookType.BOT) + .auth(auth) + .route('/dbl') + .callback(print) + .add_to_manager() + .endpoint() + .type(WebhookType.GUILD) + .auth(auth) + .route('/dsl') + .callback(print) + .add_to_manager() + ) + + +def test_WebhookManager_routes(webhook_manager: WebhookManager) -> None: + assert len(webhook_manager.app.router.routes()) == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'headers, result, state', + [({'authorization': auth}, 204, True), ({}, 401, False)], +) +async def test_WebhookManager_validates_auth( + webhook_manager: WebhookManager, headers: t.Dict[str, str], result: int, state: bool +) -> None: + await webhook_manager.start(5000) + + try: + for path in ('dbl', 'dsl'): + async with aiohttp.request( + 'POST', f'http://localhost:5000/{path}', headers=headers, json={} + ) as r: + assert r.status == result + finally: + await webhook_manager.close() + assert not webhook_manager.is_running + + +def test_WebhookEndpoint_callback_unset(webhook_manager: WebhookManager): + with pytest.raises( + TopGGException, + match='endpoint missing callback.', + ): + webhook_manager.endpoint().add_to_manager() + + +def test_WebhookEndpoint_route_unset(webhook_manager: WebhookManager): + with pytest.raises( + TopGGException, + match='endpoint missing type.', + ): + webhook_manager.endpoint().callback(mock.Mock()).add_to_manager() + + +def test_WebhookEndpoint_type_unset(webhook_manager: WebhookManager): + with pytest.raises( + TopGGException, + match='endpoint missing route.', + ): + webhook_manager.endpoint().callback(mock.Mock()).type( + WebhookType.BOT + ).add_to_manager() diff --git a/topgg/autopost.py b/topgg/autopost.py index 72ee608b..70b36767 100644 --- a/topgg/autopost.py +++ b/topgg/autopost.py @@ -68,6 +68,12 @@ def __init__(self, client: 'DBLClient') -> None: self._error = self._default_error_handler self._refresh_state() + def __repr__(self) -> str: + return f'<{__class__.__name__} is_running={self.is_running}>' + + def __bool__(self) -> bool: + return self.is_running + def _default_error_handler(self, exception: Exception) -> None: print('Ignoring exception in auto post loop:', file=sys.stderr) @@ -248,7 +254,7 @@ def set_interval(self, seconds: t.Union[float, datetime.timedelta]) -> 'AutoPost seconds = seconds.total_seconds() if seconds < 900: - raise ValueError('interval must be greated than 900 seconds.') + raise ValueError('interval must be greater than 900 seconds.') self._interval = seconds @@ -309,7 +315,7 @@ def start(self) -> 'asyncio.Task[None]': if not hasattr(self, '_stats'): raise errors.TopGGException( - 'you must provide a callback that returns the stats.' + 'You must provide a callback that returns the stats.' ) elif self.is_running: raise errors.TopGGException('The autoposter is already running.') diff --git a/topgg/client.py b/topgg/client.py index ba8acf04..04efbf4f 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -482,18 +482,22 @@ def autopost(self) -> AutoPoster: autoposter = client.autopost() + @autoposter.stats def get_stats() -> int: return topgg.StatsWrapper(bot.server_count) + @autoposter.on_success def success() -> None: print('Successfully posted statistics to the Top.gg API!') + @autoposter.on_error def error(exc: Exception) -> None: print(f'Error: {exc!r}') + autoposter.start() :returns: The autoposter instance. diff --git a/topgg/types.py b/topgg/types.py index b3cae998..3d171fc0 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -27,6 +27,7 @@ import typing as t import warnings +from urllib.parse import parse_qs from datetime import datetime from enum import Enum @@ -430,22 +431,25 @@ class VoteDataDict: __slots__: tuple[str, ...] = ('type', 'user', 'query') - type: str + type: t.Optional[str] """Vote event type. ``upvote`` (invoked from the vote page by a user) or ``test`` (invoked explicitly by the developer for testing.)""" - user: int + user: t.Optional[int] """The ID of the user who voted.""" - query: t.Optional[str] + query: dict """Query strings found on the vote page.""" def __init__(self, json: dict): - self.type = json['type'] - self.user = int(json['user']) - self.query = json.get('query') + self.type = json.get('type') + + user = json.get('user') + self.user = user and int(user) + + self.query = parse_qs(json.get('query', '')) def __repr__(self) -> str: - return f'<{__class__.__name__} type={self.type!r} user={self.user}>' + return f'<{__class__.__name__} type={self.type!r} user={self.user} query={self.query!r}>' class BotVoteData(VoteDataDict): @@ -453,7 +457,7 @@ class BotVoteData(VoteDataDict): __slots__: tuple[str, ...] = ('bot', 'is_weekend') - bot: int + bot: t.Optional[int] """The ID of the bot that received a vote.""" is_weekend: bool @@ -462,8 +466,10 @@ class BotVoteData(VoteDataDict): def __init__(self, json: dict): super().__init__(json) - self.bot = int(json['bot']) - self.is_weekend = json['isWeekend'] + bot = json.get('bot') + self.bot = bot and int(bot) + + self.is_weekend = json.get('isWeekend', False) def __repr__(self) -> str: return f'<{__class__.__name__} type={self.type!r} user={self.user} is_weekend={self.is_weekend}>' @@ -474,13 +480,14 @@ class GuildVoteData(VoteDataDict): __slots__: tuple[str, ...] = ('guild',) - guild: int + guild: t.Optional[int] """The ID of the server that received a vote.""" def __init__(self, json: dict): super().__init__(json) - self.guild = int(json['guild']) + guild = json.get('guild') + self.guild = guild and int(guild) ServerVoteData = GuildVoteData diff --git a/topgg/webhook.py b/topgg/webhook.py index acaa5130..7340ae9c 100644 --- a/topgg/webhook.py +++ b/topgg/webhook.py @@ -61,6 +61,9 @@ def __init__(self) -> None: self.__app = web.Application() self._is_running = False + def __repr__(self) -> str: + return f'<{__class__.__name__} is_running={self.is_running}>' + @t.overload def endpoint(self, endpoint_: None = None) -> 'BoundWebhookEndpoint': ... @@ -83,10 +86,14 @@ def endpoint( """ if endpoint_: - if not isinstance(endpoint_, WebhookEndpoint): - raise TopGGException( - f'endpoint_ must be an instance of WebhookEndpoint, got {endpoint_.__class__.__name__}.' - ) + if not hasattr(endpoint_, '_callback'): + raise TopGGException('endpoint missing callback.') + + if not hasattr(endpoint_, '_type'): + raise TopGGException('endpoint missing type.') + + if not hasattr(endpoint_, '_route'): + raise TopGGException('endpoint missing route.') self.app.router.add_post( endpoint_._route, @@ -97,7 +104,7 @@ def endpoint( return self - return BoundWebhookEndpoint(manager=self) + return BoundWebhookEndpoint(self) async def start(self, port: int) -> None: """ @@ -166,6 +173,9 @@ def __init__(self) -> None: def __call__(self, *args: t.Any, **kwargs: t.Any) -> t.Any: return self._callback(*args, **kwargs) + def __repr__(self) -> str: + return f'<{__class__.__name__} type={self._type!r} route={self._route!r}>' + def type(self: T, type_: WebhookType) -> T: """ Sets the type of this endpoint. @@ -312,6 +322,9 @@ def __init__(self, manager: WebhookManager): self.manager = manager + def __repr__(self) -> str: + return f'<{__class__.__name__} manager={self.manager!r}>' + def add_to_manager(self) -> WebhookManager: """ Adds this endpoint to the webhook manager. From 44e3974ce9cd801a93231863b2b6265993f2d4fc Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 21 Oct 2025 15:54:20 +0700 Subject: [PATCH 13/19] fix: fix autoposter tests not working --- tests/test_autopost.py | 49 ++++++++++++++++++++---------------------- tests/test_client.py | 29 ++++++++++++++----------- 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/tests/test_autopost.py b/tests/test_autopost.py index 14e92c74..b0f7a5d9 100644 --- a/tests/test_autopost.py +++ b/tests/test_autopost.py @@ -23,28 +23,28 @@ def autopost(session: ClientSession) -> AutoPoster: return AutoPoster(DBLClient(MOCK_TOKEN, session=session)) -# @pytest.mark.asyncio -# async def test_AutoPoster_breaks_autopost_loop_on_401( -# mocker: MockerFixture, session: ClientSession -# ) -> None: -# response = mock.Mock("reason, status") -# response.reason = "Unauthorized" -# response.status = 401 -# -# mocker.patch( -# "topgg.DBLClient.post_guild_count", side_effect=HTTPException(response, {}) -# ) -# -# callback = mock.Mock() -# autopost = DBLClient(MOCK_TOKEN, session=session).autopost().stats(callback) -# assert isinstance(autopost, AutoPoster) -# assert not isinstance(autopost.stats()(callback), AutoPoster) -# -# with pytest.raises(HTTPException): -# await autopost.start() -# -# callback.assert_called_once() -# assert not autopost.is_running +@pytest.mark.asyncio +async def test_AutoPoster_breaks_autopost_loop_on_401( + mocker: MockerFixture, session: ClientSession +) -> None: + mocker.patch( + 'topgg.DBLClient.post_guild_count', + side_effect=HTTPException('Unauthorized', 401), + ) + + callback = mock.Mock() + autopost = DBLClient(MOCK_TOKEN, session=session).autopost().stats(callback) + + assert isinstance(autopost, AutoPoster) + assert not isinstance(autopost.stats()(callback), AutoPoster) + + autopost._interval = 1 + + with pytest.raises(HTTPException): + await autopost.start() + + callback.assert_called_once() + assert not autopost.is_running @pytest.mark.asyncio @@ -73,10 +73,7 @@ async def test_AutoPoster_error_callback( mocker: MockerFixture, autopost: AutoPoster ) -> None: error_callback = mock.Mock() - response = mock.Mock('reason, status') - response.reason = 'Internal Server Error' - response.status = 500 - side_effect = HTTPException(response, {}) + side_effect = HTTPException('Internal Server Error', 500) mocker.patch('topgg.DBLClient.post_guild_count', side_effect=side_effect) task = autopost.on_error(error_callback).stats(mock.Mock()).start() diff --git a/tests/test_client.py b/tests/test_client.py index 67f032c2..6b13f054 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,6 @@ import mock import pytest +from aiohttp import ClientSession import topgg @@ -7,32 +8,38 @@ MOCK_TOKEN = '.eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=.' +@pytest.fixture +def session() -> ClientSession: + return mock.Mock(ClientSession) + + +@pytest.fixture +def client(session: ClientSession) -> topgg.DBLClient: + return topgg.DBLClient(MOCK_TOKEN, session=session) + + @pytest.mark.asyncio -async def test_DBLClient_post_guild_count_with_no_args(): - client = topgg.DBLClient(MOCK_TOKEN) +async def test_DBLClient_post_guild_count_with_no_args(client: topgg.DBLClient): with pytest.raises(ValueError, match='Got an invalid server count. Got None.'): await client.post_guild_count() @pytest.mark.asyncio -async def test_DBLClient_get_weekend_status(monkeypatch): - client = topgg.DBLClient(MOCK_TOKEN) +async def test_DBLClient_get_weekend_status(monkeypatch, client: topgg.DBLClient): monkeypatch.setattr('topgg.DBLClient._DBLClient__request', mock.AsyncMock()) await client.get_weekend_status() client._DBLClient__request.assert_called_once() @pytest.mark.asyncio -async def test_DBLClient_post_guild_count(monkeypatch): - client = topgg.DBLClient(MOCK_TOKEN) +async def test_DBLClient_post_guild_count(monkeypatch, client: topgg.DBLClient): monkeypatch.setattr('topgg.DBLClient._DBLClient__request', mock.AsyncMock()) await client.post_guild_count(guild_count=123) client._DBLClient__request.assert_called_once() @pytest.mark.asyncio -async def test_DBLClient_get_guild_count(monkeypatch): - client = topgg.DBLClient(MOCK_TOKEN) +async def test_DBLClient_get_guild_count(monkeypatch, client: topgg.DBLClient): monkeypatch.setattr( 'topgg.DBLClient._DBLClient__request', mock.AsyncMock(return_value={}) ) @@ -41,8 +48,7 @@ async def test_DBLClient_get_guild_count(monkeypatch): @pytest.mark.asyncio -async def test_DBLClient_get_bot_votes(monkeypatch): - client = topgg.DBLClient(MOCK_TOKEN) +async def test_DBLClient_get_bot_votes(monkeypatch, client: topgg.DBLClient): monkeypatch.setattr( 'topgg.DBLClient._DBLClient__request', mock.AsyncMock(return_value=[]) ) @@ -51,8 +57,7 @@ async def test_DBLClient_get_bot_votes(monkeypatch): @pytest.mark.asyncio -async def test_DBLClient_get_user_vote(monkeypatch): - client = topgg.DBLClient(MOCK_TOKEN) +async def test_DBLClient_get_user_vote(monkeypatch, client: topgg.DBLClient): monkeypatch.setattr( 'topgg.DBLClient._DBLClient__request', mock.AsyncMock(return_value={'voted': 1}) ) From a52e32e0ff23b8557d4ba6288d4a15f1b83869a5 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 21 Oct 2025 15:55:07 +0700 Subject: [PATCH 14/19] meta: update MANIFEST.in to include tests --- MANIFEST.in | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 921ba3b5..3bd177d4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,11 +2,10 @@ prune .github prune .ruff_cache prune docs prune examples +prune tests exclude .gitignore exclude .readthedocs.yml exclude ruff.toml -exclude test.py -exclude test_autoposter.py exclude LICENSE exclude ISSUE_TEMPLATE.md exclude PULL_REQUEST_TEMPLATE.md \ No newline at end of file From d25f878fab3a1d280a00e7cef3b41293d182b424 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 21 Oct 2025 16:04:59 +0700 Subject: [PATCH 15/19] style: continue using double quotes --- docs/conf.py | 64 +++--- examples/discordpy_example/__main__.py | 4 +- .../discordpy_example/callbacks/autopost.py | 4 +- .../discordpy_example/callbacks/webhook.py | 4 +- examples/hikari_example/__main__.py | 4 +- examples/hikari_example/callbacks/autopost.py | 6 +- examples/hikari_example/callbacks/webhook.py | 6 +- ruff.toml | 2 +- tests/test_autopost.py | 16 +- tests/test_client.py | 14 +- tests/test_data_container.py | 10 +- tests/test_type.py | 88 ++++---- tests/test_webhook.py | 20 +- topgg/__init__.py | 80 +++---- topgg/autopost.py | 54 ++--- topgg/client.py | 106 ++++----- topgg/data.py | 12 +- topgg/errors.py | 12 +- topgg/ratelimiter.py | 12 +- topgg/types.py | 204 +++++++++--------- topgg/version.py | 2 +- topgg/webhook.py | 64 +++--- 22 files changed, 394 insertions(+), 394 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 27fd7ee8..2d368576 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ import alabaster -sys.path.insert(0, os.path.abspath('../')) +sys.path.insert(0, os.path.abspath("../")) from topgg import __version__ as version # import re @@ -39,44 +39,44 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.viewcode', - 'sphinx.ext.autosectionlabel', - 'sphinx.ext.extlinks', - 'sphinx.ext.intersphinx', - 'sphinx.ext.napoleon', + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", + "sphinx.ext.autosectionlabel", + "sphinx.ext.extlinks", + "sphinx.ext.intersphinx", + "sphinx.ext.napoleon", ] -autodoc_member_order = 'groupwise' +autodoc_member_order = "groupwise" extlinks = { - 'issue': ('https://github.com/top-gg/python-sdk/issues/%s', 'GH-'), + "issue": ("https://github.com/top-gg/python-sdk/issues/%s", "GH-"), } intersphinx_mapping = { - 'py': ('https://docs.python.org/3', None), - 'discord': ('https://discordpy.readthedocs.io/en/latest/', None), - 'aiohttp': ('https://docs.aiohttp.org/en/stable/', None), + "py": ("https://docs.python.org/3", None), + "discord": ("https://discordpy.readthedocs.io/en/latest/", None), + "aiohttp": ("https://docs.aiohttp.org/en/stable/", None), } -releases_github_path = 'top-gg/python-sdk' +releases_github_path = "top-gg/python-sdk" # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = 'topggpy' -copyright = '2021, Assanali Mukhanov' -author = 'Assanali Mukhanov' +project = "topggpy" +copyright = "2021, Assanali Mukhanov" +author = "Assanali Mukhanov" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -99,25 +99,25 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # -- Options for HTML output ---------------------------------------------- -html_theme_options = {'navigation_depth': 2} +html_theme_options = {"navigation_depth": 2} html_theme_path = [alabaster.get_path()] # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'insegel' +html_theme = "insegel" # The name of an image file (relative to this directory) to place at the top # of the sidebar. -html_logo = 'topgg.svg' +html_logo = "topgg.svg" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied @@ -183,7 +183,7 @@ # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. -htmlhelp_basename = 'topggpydoc' +htmlhelp_basename = "topggpydoc" # -- Options for LaTeX output --------------------------------------------- @@ -206,14 +206,14 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'topggpy.tex', 'topggpy Documentation', 'Assanali Mukhanov', 'manual'), + (master_doc, "topggpy.tex", "topggpy Documentation", "Assanali Mukhanov", "manual"), ] # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [(master_doc, 'topggpy', 'topggpy Documentation', [author], 1)] +man_pages = [(master_doc, "topggpy", "topggpy Documentation", [author], 1)] # -- Options for Texinfo output ------------------------------------------- @@ -223,11 +223,11 @@ texinfo_documents = [ ( master_doc, - 'topggpy', - 'topggpy Documentation', + "topggpy", + "topggpy Documentation", author, - 'topggpy', - 'One line description of project.', - 'Miscellaneous', + "topggpy", + "One line description of project.", + "Miscellaneous", ), ] diff --git a/examples/discordpy_example/__main__.py b/examples/discordpy_example/__main__.py index 5e40e092..0550068e 100644 --- a/examples/discordpy_example/__main__.py +++ b/examples/discordpy_example/__main__.py @@ -30,7 +30,7 @@ client = discord.Client() webhook_manager = topgg.WebhookManager().set_data(client).endpoint(webhook.endpoint) -dblclient = topgg.DBLClient('TOPGG_TOKEN').set_data(client) +dblclient = topgg.DBLClient("TOPGG_TOKEN").set_data(client) autoposter: topgg.AutoPoster = ( dblclient.autopost() .on_success(autopost.on_autopost_success) @@ -58,4 +58,4 @@ async def on_ready(): # TODO: find a way to figure out when the bot shuts down # so we can close the client and the webhook manager -client.run('TOKEN') +client.run("TOKEN") diff --git a/examples/discordpy_example/callbacks/autopost.py b/examples/discordpy_example/callbacks/autopost.py index 7808f3a9..76da7d8b 100644 --- a/examples/discordpy_example/callbacks/autopost.py +++ b/examples/discordpy_example/callbacks/autopost.py @@ -35,7 +35,7 @@ def on_autopost_success( # client: discord.Client = topgg.data(discord.Client) ): # will be called whenever it successfully posting - print('Successfully posted!') + print("Successfully posted!") # do whatever with client # you can dispatch your own event for more callbacks @@ -48,7 +48,7 @@ def on_autopost_error( # client: discord.Client = topgg.data(discord.Client), ): # will be called whenever it failed posting - print('Failed to post:', exception, file=sys.stderr) + print("Failed to post:", exception, file=sys.stderr) # do whatever with client # you can dispatch your own event for more callbacks diff --git a/examples/discordpy_example/callbacks/webhook.py b/examples/discordpy_example/callbacks/webhook.py index 6d710b20..299327f4 100644 --- a/examples/discordpy_example/callbacks/webhook.py +++ b/examples/discordpy_example/callbacks/webhook.py @@ -28,14 +28,14 @@ # this can be async too! -@topgg.endpoint('/dblwebhook', topgg.WebhookType.BOT, 'youshallnotpass') +@topgg.endpoint("/dblwebhook", topgg.WebhookType.BOT, "youshallnotpass") def endpoint( vote_data: topgg.BotVoteData, # uncomment this if you want to get access to client # client: discord.Client = topgg.data(discord.Client), ): # this function will be called whenever someone votes for your bot. - print('Received a vote!', vote_data) + print("Received a vote!", vote_data) # do anything with client here # client.dispatch("dbl_vote", vote_data) diff --git a/examples/hikari_example/__main__.py b/examples/hikari_example/__main__.py index 26c3b240..b1111190 100644 --- a/examples/hikari_example/__main__.py +++ b/examples/hikari_example/__main__.py @@ -28,9 +28,9 @@ from .callbacks import autopost, webhook -app = hikari.GatewayBot('TOKEN') +app = hikari.GatewayBot("TOKEN") webhook_manager = topgg.WebhookManager().set_data(app).endpoint(webhook.endpoint) -dblclient = topgg.DBLClient('TOPGG_TOKEN').set_data(app) +dblclient = topgg.DBLClient("TOPGG_TOKEN").set_data(app) autoposter: topgg.AutoPoster = ( dblclient.autopost() .on_success(autopost.on_autopost_success) diff --git a/examples/hikari_example/callbacks/autopost.py b/examples/hikari_example/callbacks/autopost.py index a4d699bd..dd737fd3 100644 --- a/examples/hikari_example/callbacks/autopost.py +++ b/examples/hikari_example/callbacks/autopost.py @@ -30,7 +30,7 @@ # from ..events.autopost import AutoPostErrorEvent, AutoPostSuccessEvent -_LOGGER = logging.getLogger('callbacks.autopost') +_LOGGER = logging.getLogger("callbacks.autopost") # these functions can be async too! @@ -39,7 +39,7 @@ def on_autopost_success( # app: hikari.GatewayBot = topgg.data(hikari.GatewayBot), ): # will be called whenever it successfully posting - _LOGGER.info('Successfully posted!') + _LOGGER.info("Successfully posted!") # do whatever with app # you can dispatch your own event for more callbacks @@ -52,7 +52,7 @@ def on_autopost_error( # app: hikari.GatewayBot = topgg.data(hikari.GatewayBot), ): # will be called whenever it failed posting - _LOGGER.error('Failed to post...', exc_info=exception) + _LOGGER.error("Failed to post...", exc_info=exception) # do whatever with app # you can dispatch your own event for more callbacks diff --git a/examples/hikari_example/callbacks/webhook.py b/examples/hikari_example/callbacks/webhook.py index 0441c3ee..5c5d722d 100644 --- a/examples/hikari_example/callbacks/webhook.py +++ b/examples/hikari_example/callbacks/webhook.py @@ -31,17 +31,17 @@ # from ..events import BotUpvoteEvent -_LOGGER = logging.getLogger('callbacks.webhook') +_LOGGER = logging.getLogger("callbacks.webhook") # this can be async too! -@topgg.endpoint('/dblwebhook', topgg.WebhookType.BOT, 'youshallnotpass') +@topgg.endpoint("/dblwebhook", topgg.WebhookType.BOT, "youshallnotpass") async def endpoint( vote_data: topgg.BotVoteData, # uncomment this if you want to get access to app # app: hikari.GatewayBot = topgg.data(hikari.GatewayBot), ): # this function will be called whenever someone votes for your bot. - _LOGGER.info('Receives a vote! %s', vote_data) + _LOGGER.info("Receives a vote! %s", vote_data) # do anything with app here. # app.dispatch(BotUpvoteEvent(app=app, data=vote_data)) diff --git a/ruff.toml b/ruff.toml index 6082f28b..730e2221 100644 --- a/ruff.toml +++ b/ruff.toml @@ -4,7 +4,7 @@ indent-width = 4 docstring-code-format = true docstring-code-line-length = 88 line-ending = "lf" -quote-style = "single" +quote-style = "double" [lint] ignore = ["E402"] \ No newline at end of file diff --git a/tests/test_autopost.py b/tests/test_autopost.py index b0f7a5d9..aee465de 100644 --- a/tests/test_autopost.py +++ b/tests/test_autopost.py @@ -10,7 +10,7 @@ from topgg.errors import HTTPException, TopGGException -MOCK_TOKEN = '.eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=.' +MOCK_TOKEN = ".eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=." @pytest.fixture @@ -28,8 +28,8 @@ async def test_AutoPoster_breaks_autopost_loop_on_401( mocker: MockerFixture, session: ClientSession ) -> None: mocker.patch( - 'topgg.DBLClient.post_guild_count', - side_effect=HTTPException('Unauthorized', 401), + "topgg.DBLClient.post_guild_count", + side_effect=HTTPException("Unauthorized", 401), ) callback = mock.Mock() @@ -50,7 +50,7 @@ async def test_AutoPoster_breaks_autopost_loop_on_401( @pytest.mark.asyncio async def test_AutoPoster_raises_missing_stats(autopost: AutoPoster) -> None: with pytest.raises( - TopGGException, match='You must provide a callback that returns the stats.' + TopGGException, match="You must provide a callback that returns the stats." ): await autopost.start() @@ -58,13 +58,13 @@ async def test_AutoPoster_raises_missing_stats(autopost: AutoPoster) -> None: @pytest.mark.asyncio async def test_AutoPoster_raises_already_running(autopost: AutoPoster) -> None: autopost.stats(mock.Mock()).start() - with pytest.raises(TopGGException, match='The autoposter is already running.'): + with pytest.raises(TopGGException, match="The autoposter is already running."): await autopost.start() @pytest.mark.asyncio async def test_AutoPoster_interval_too_short(autopost: AutoPoster) -> None: - with pytest.raises(ValueError, match='interval must be greater than 900 seconds.'): + with pytest.raises(ValueError, match="interval must be greater than 900 seconds."): autopost.set_interval(50) @@ -73,9 +73,9 @@ async def test_AutoPoster_error_callback( mocker: MockerFixture, autopost: AutoPoster ) -> None: error_callback = mock.Mock() - side_effect = HTTPException('Internal Server Error', 500) + side_effect = HTTPException("Internal Server Error", 500) - mocker.patch('topgg.DBLClient.post_guild_count', side_effect=side_effect) + mocker.patch("topgg.DBLClient.post_guild_count", side_effect=side_effect) task = autopost.on_error(error_callback).stats(mock.Mock()).start() autopost.stop() await task diff --git a/tests/test_client.py b/tests/test_client.py index 6b13f054..f26895a0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -5,7 +5,7 @@ import topgg -MOCK_TOKEN = '.eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=.' +MOCK_TOKEN = ".eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=." @pytest.fixture @@ -20,20 +20,20 @@ def client(session: ClientSession) -> topgg.DBLClient: @pytest.mark.asyncio async def test_DBLClient_post_guild_count_with_no_args(client: topgg.DBLClient): - with pytest.raises(ValueError, match='Got an invalid server count. Got None.'): + with pytest.raises(ValueError, match="Got an invalid server count. Got None."): await client.post_guild_count() @pytest.mark.asyncio async def test_DBLClient_get_weekend_status(monkeypatch, client: topgg.DBLClient): - monkeypatch.setattr('topgg.DBLClient._DBLClient__request', mock.AsyncMock()) + monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock()) await client.get_weekend_status() client._DBLClient__request.assert_called_once() @pytest.mark.asyncio async def test_DBLClient_post_guild_count(monkeypatch, client: topgg.DBLClient): - monkeypatch.setattr('topgg.DBLClient._DBLClient__request', mock.AsyncMock()) + monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock()) await client.post_guild_count(guild_count=123) client._DBLClient__request.assert_called_once() @@ -41,7 +41,7 @@ async def test_DBLClient_post_guild_count(monkeypatch, client: topgg.DBLClient): @pytest.mark.asyncio async def test_DBLClient_get_guild_count(monkeypatch, client: topgg.DBLClient): monkeypatch.setattr( - 'topgg.DBLClient._DBLClient__request', mock.AsyncMock(return_value={}) + "topgg.DBLClient._DBLClient__request", mock.AsyncMock(return_value={}) ) await client.get_guild_count() client._DBLClient__request.assert_called_once() @@ -50,7 +50,7 @@ async def test_DBLClient_get_guild_count(monkeypatch, client: topgg.DBLClient): @pytest.mark.asyncio async def test_DBLClient_get_bot_votes(monkeypatch, client: topgg.DBLClient): monkeypatch.setattr( - 'topgg.DBLClient._DBLClient__request', mock.AsyncMock(return_value=[]) + "topgg.DBLClient._DBLClient__request", mock.AsyncMock(return_value=[]) ) await client.get_bot_votes() client._DBLClient__request.assert_called_once() @@ -59,7 +59,7 @@ async def test_DBLClient_get_bot_votes(monkeypatch, client: topgg.DBLClient): @pytest.mark.asyncio async def test_DBLClient_get_user_vote(monkeypatch, client: topgg.DBLClient): monkeypatch.setattr( - 'topgg.DBLClient._DBLClient__request', mock.AsyncMock(return_value={'voted': 1}) + "topgg.DBLClient._DBLClient__request", mock.AsyncMock(return_value={"voted": 1}) ) await client.get_user_vote(1234) client._DBLClient__request.assert_called_once() diff --git a/tests/test_data_container.py b/tests/test_data_container.py index 75230fdc..0fd1bede 100644 --- a/tests/test_data_container.py +++ b/tests/test_data_container.py @@ -7,9 +7,9 @@ @pytest.fixture def data_container() -> DataContainerMixin: dc = DataContainerMixin() - dc.set_data('TEXT') + dc.set_data("TEXT") dc.set_data(200) - dc.set_data({'a': 'b'}) + dc.set_data({"a": "b"}) return dc @@ -40,9 +40,9 @@ def test_data_container_raises_data_already_exists(data_container: DataContainer with pytest.raises( TopGGException, match=" already exists. If you wish to override it, " - 'pass True into the override parameter.', + "pass True into the override parameter.", ): - data_container.set_data('TEST') + data_container.set_data("TEST") @pytest.mark.asyncio @@ -52,6 +52,6 @@ async def test_data_container_raises_key_error(data_container: DataContainerMixi def test_data_container_get_data(data_container: DataContainerMixin): - assert data_container.get_data(str) == 'TEXT' + assert data_container.get_data(str) == "TEXT" assert data_container.get_data(float) is None assert isinstance(data_container.get_data(set, set()), set) diff --git a/tests/test_type.py b/tests/test_type.py index 1aab09fe..b30bfcf1 100644 --- a/tests/test_type.py +++ b/tests/test_type.py @@ -3,54 +3,54 @@ from topgg import types d: dict = { - 'invite': 'https://top.gg/discord', - 'support': 'https://discord.gg/dbl', - 'github': 'https://github.com/top-gg', - 'longdesc': "A bot to grant API access to our Library Developers on the Top.gg site without them needing to submit a bot to pass verification just to be able to access the API.\n\nThis is not a real bot, so if you happen to find this page, do not try to invite it. It will not work.\n\nAccess to this bot's team can be requested by contacting a Community Manager in [our Discord server](https://top.gg/discord).", - 'shortdesc': 'API access for Top.gg Library Developers', - 'prefix': '/', - 'lib': '', - 'clientid': '1026525568344264724', - 'avatar': 'https://cdn.discordapp.com/avatars/1026525568344264724/cd70e62e41f691f1c05c8455d8c31e23.png', - 'id': '1026525568344264724', - 'username': 'Top.gg Lib Dev API Access', - 'date': '2022-10-03T16:08:55.292Z', - 'server_count': 2, - 'shard_count': 0, - 'guilds': [], - 'shards': [], - 'monthlyPoints': 2, - 'points': 28, - 'certifiedBot': False, - 'owners': ['121919449996460033'], - 'tags': ['api', 'library', 'topgg'], - 'reviews': {'averageScore': 5, 'count': 2}, + "invite": "https://top.gg/discord", + "support": "https://discord.gg/dbl", + "github": "https://github.com/top-gg", + "longdesc": "A bot to grant API access to our Library Developers on the Top.gg site without them needing to submit a bot to pass verification just to be able to access the API.\n\nThis is not a real bot, so if you happen to find this page, do not try to invite it. It will not work.\n\nAccess to this bot's team can be requested by contacting a Community Manager in [our Discord server](https://top.gg/discord).", + "shortdesc": "API access for Top.gg Library Developers", + "prefix": "/", + "lib": "", + "clientid": "1026525568344264724", + "avatar": "https://cdn.discordapp.com/avatars/1026525568344264724/cd70e62e41f691f1c05c8455d8c31e23.png", + "id": "1026525568344264724", + "username": "Top.gg Lib Dev API Access", + "date": "2022-10-03T16:08:55.292Z", + "server_count": 2, + "shard_count": 0, + "guilds": [], + "shards": [], + "monthlyPoints": 2, + "points": 28, + "certifiedBot": False, + "owners": ["121919449996460033"], + "tags": ["api", "library", "topgg"], + "reviews": {"averageScore": 5, "count": 2}, } -query_dict = {'qwe': '1', 'rty': '2', 'uio': '3'} +query_dict = {"qwe": "1", "rty": "2", "uio": "3"} vote_data_dict = { - 'type': 'test', - 'query': '?' + '&'.join(f'{k}={v}' for k, v in query_dict.items()), - 'user': '1', + "type": "test", + "query": "?" + "&".join(f"{k}={v}" for k, v in query_dict.items()), + "user": "1", } bot_vote_dict = { - 'bot': '2', - 'user': '3', - 'type': 'test', - 'query': '?' + '&'.join(f'{k}={v}' for k, v in query_dict.items()), - 'isWeekend': False, + "bot": "2", + "user": "3", + "type": "test", + "query": "?" + "&".join(f"{k}={v}" for k, v in query_dict.items()), + "isWeekend": False, } server_vote_dict = { - 'guild': '4', - 'user': '5', - 'type': 'upvote', - 'query': '?' + '&'.join(f'{k}={v}' for k, v in query_dict.items()), + "guild": "4", + "user": "5", + "type": "upvote", + "query": "?" + "&".join(f"{k}={v}" for k, v in query_dict.items()), } -bot_stats_dict = {'server_count': 2, 'shards': [], 'shard_count': 0} +bot_stats_dict = {"server_count": 2, "shards": [], "shard_count": 0} @pytest.fixture @@ -61,7 +61,7 @@ def bot_data() -> types.BotData: @pytest.fixture def widget_options() -> types.WidgetOptions: return types.WidgetOptions( - id=int(d['id']), + id=int(d["id"]), project_type=types.WidgetProjectType.DISCORD_BOT, type=types.WidgetType.LARGE, ) @@ -89,39 +89,39 @@ def bot_stats_data() -> types.BotStatsData: def test_bot_data_fields(bot_data: types.BotData) -> None: bot_data.github = "I'm a GitHub link!" - bot_data.support = 'Support has arrived!' + bot_data.support = "Support has arrived!" for attr in bot_data.__slots__: - if 'id' in attr.lower(): + if "id" in attr.lower(): value = getattr(bot_data, attr) assert isinstance(value, int) or value is None - elif attr in ('owners', 'guilds'): + elif attr in ("owners", "guilds"): for item in getattr(bot_data, attr): assert isinstance(item, int) def test_vote_data_fields(vote_data: types.VoteDataDict) -> None: assert isinstance(vote_data.query, dict) - vote_data.type = 'upvote' + vote_data.type = "upvote" def test_bot_vote_data_fields(bot_vote_data: types.BotVoteData) -> None: assert isinstance(bot_vote_data.query, dict) - bot_vote_data.type = 'upvote' + bot_vote_data.type = "upvote" assert isinstance(bot_vote_data.bot, int) def test_server_vote_data_fields(server_vote_data: types.BotVoteData) -> None: assert isinstance(server_vote_data.query, dict) - server_vote_data.type = 'upvote' + server_vote_data.type = "upvote" assert isinstance(server_vote_data.guild, int) def test_bot_stats_data_attrs(bot_stats_data: types.BotStatsData) -> None: - for count in ('server_count', 'shard_count'): + for count in ("server_count", "shard_count"): value = getattr(bot_stats_data, count) assert isinstance(value, int) or value is None diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 62e86cbb..863fd627 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -7,7 +7,7 @@ from topgg import WebhookManager, WebhookType from topgg.errors import TopGGException -auth = 'youshallnotpass' +auth = "youshallnotpass" @pytest.fixture @@ -17,13 +17,13 @@ def webhook_manager() -> WebhookManager: .endpoint() .type(WebhookType.BOT) .auth(auth) - .route('/dbl') + .route("/dbl") .callback(print) .add_to_manager() .endpoint() .type(WebhookType.GUILD) .auth(auth) - .route('/dsl') + .route("/dsl") .callback(print) .add_to_manager() ) @@ -35,8 +35,8 @@ def test_WebhookManager_routes(webhook_manager: WebhookManager) -> None: @pytest.mark.asyncio @pytest.mark.parametrize( - 'headers, result, state', - [({'authorization': auth}, 204, True), ({}, 401, False)], + "headers, result, state", + [({"authorization": auth}, 204, True), ({}, 401, False)], ) async def test_WebhookManager_validates_auth( webhook_manager: WebhookManager, headers: t.Dict[str, str], result: int, state: bool @@ -44,9 +44,9 @@ async def test_WebhookManager_validates_auth( await webhook_manager.start(5000) try: - for path in ('dbl', 'dsl'): + for path in ("dbl", "dsl"): async with aiohttp.request( - 'POST', f'http://localhost:5000/{path}', headers=headers, json={} + "POST", f"http://localhost:5000/{path}", headers=headers, json={} ) as r: assert r.status == result finally: @@ -57,7 +57,7 @@ async def test_WebhookManager_validates_auth( def test_WebhookEndpoint_callback_unset(webhook_manager: WebhookManager): with pytest.raises( TopGGException, - match='endpoint missing callback.', + match="endpoint missing callback.", ): webhook_manager.endpoint().add_to_manager() @@ -65,7 +65,7 @@ def test_WebhookEndpoint_callback_unset(webhook_manager: WebhookManager): def test_WebhookEndpoint_route_unset(webhook_manager: WebhookManager): with pytest.raises( TopGGException, - match='endpoint missing type.', + match="endpoint missing type.", ): webhook_manager.endpoint().callback(mock.Mock()).add_to_manager() @@ -73,7 +73,7 @@ def test_WebhookEndpoint_route_unset(webhook_manager: WebhookManager): def test_WebhookEndpoint_type_unset(webhook_manager: WebhookManager): with pytest.raises( TopGGException, - match='endpoint missing route.', + match="endpoint missing route.", ): webhook_manager.endpoint().callback(mock.Mock()).type( WebhookType.BOT diff --git a/topgg/__init__.py b/topgg/__init__.py index d5ed4ca3..b5290786 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -60,46 +60,46 @@ ) -__title__ = 'topggpy' -__author__ = 'null8626 & Top.gg' -__credits__ = ('null8626', 'Top.gg') -__maintainer__ = 'null8626' -__status__ = 'Production' -__license__ = 'MIT' -__copyright__ = 'Copyright (c) 2021 Assanali Mukhanov & Top.gg; Copyright (c) 2024-2025 null8626 & Top.gg' +__title__ = "topggpy" +__author__ = "null8626 & Top.gg" +__credits__ = ("null8626", "Top.gg") +__maintainer__ = "null8626" +__status__ = "Production" +__license__ = "MIT" +__copyright__ = "Copyright (c) 2021 Assanali Mukhanov & Top.gg; Copyright (c) 2024-2025 null8626 & Top.gg" __version__ = VERSION __all__ = ( - 'AutoPoster', - 'BotData', - 'BotsData', - 'BotStatsData', - 'BotVoteData', - 'BoundWebhookEndpoint', - 'BriefUserData', - 'ClientException', - 'ClientStateException', - 'data', - 'DataContainerMixin', - 'DBLClient', - 'endpoint', - 'GuildVoteData', - 'HTTPException', - 'Ratelimited', - 'RequestError', - 'ServerVoteData', - 'SocialData', - 'SortBy', - 'StatsWrapper', - 'TopGGException', - 'UserData', - 'VERSION', - 'VoteDataDict', - 'VoteEvent', - 'Voter', - 'WebhookEndpoint', - 'WebhookManager', - 'WebhookType', - 'WidgetOptions', - 'WidgetProjectType', - 'WidgetType', + "AutoPoster", + "BotData", + "BotsData", + "BotStatsData", + "BotVoteData", + "BoundWebhookEndpoint", + "BriefUserData", + "ClientException", + "ClientStateException", + "data", + "DataContainerMixin", + "DBLClient", + "endpoint", + "GuildVoteData", + "HTTPException", + "Ratelimited", + "RequestError", + "ServerVoteData", + "SocialData", + "SortBy", + "StatsWrapper", + "TopGGException", + "UserData", + "VERSION", + "VoteDataDict", + "VoteEvent", + "Voter", + "WebhookEndpoint", + "WebhookManager", + "WebhookType", + "WidgetOptions", + "WidgetProjectType", + "WidgetType", ) diff --git a/topgg/autopost.py b/topgg/autopost.py index 70b36767..4ee10630 100644 --- a/topgg/autopost.py +++ b/topgg/autopost.py @@ -37,7 +37,7 @@ CallbackT = t.Callable[..., t.Any] -StatsCallbackT = t.Callable[[], 'StatsWrapper'] +StatsCallbackT = t.Callable[[], "StatsWrapper"] class AutoPoster: @@ -51,16 +51,16 @@ class AutoPoster: """ __slots__: tuple[str, ...] = ( - '_error', - '_success', - '_interval', - '_task', - 'client', - '_stats', - '_stopping', + "_error", + "_success", + "_interval", + "_task", + "client", + "_stats", + "_stopping", ) - def __init__(self, client: 'DBLClient') -> None: + def __init__(self, client: "DBLClient") -> None: super().__init__() self.client = client @@ -69,13 +69,13 @@ def __init__(self, client: 'DBLClient') -> None: self._refresh_state() def __repr__(self) -> str: - return f'<{__class__.__name__} is_running={self.is_running}>' + return f"<{__class__.__name__} is_running={self.is_running}>" def __bool__(self) -> bool: return self.is_running def _default_error_handler(self, exception: Exception) -> None: - print('Ignoring exception in auto post loop:', file=sys.stderr) + print("Ignoring exception in auto post loop:", file=sys.stderr) traceback.print_exception( type(exception), exception, exception.__traceback__, file=sys.stderr @@ -85,9 +85,9 @@ def _default_error_handler(self, exception: Exception) -> None: def on_success(self, callback: None) -> t.Callable[[CallbackT], CallbackT]: ... @t.overload - def on_success(self, callback: CallbackT) -> 'AutoPoster': ... + def on_success(self, callback: CallbackT) -> "AutoPoster": ... - def on_success(self, callback: t.Any = None) -> t.Union[t.Any, 'AutoPoster']: + def on_success(self, callback: t.Any = None) -> t.Union[t.Any, "AutoPoster"]: """ Registers an autopost success callback. The callback can be either sync or async. @@ -97,7 +97,7 @@ def on_success(self, callback: t.Any = None) -> t.Union[t.Any, 'AutoPoster']: .. code-block:: python # The following are valid. - autoposter = client.autopost().on_success(lambda: print('Success!')) + autoposter = client.autopost().on_success(lambda: print("Success!")) # Used as decorator, the decorated function will become the AutoPoster object. @@ -132,9 +132,9 @@ def decorator(callback: CallbackT) -> CallbackT: def on_error(self, callback: None) -> t.Callable[[CallbackT], CallbackT]: ... @t.overload - def on_error(self, callback: CallbackT) -> 'AutoPoster': ... + def on_error(self, callback: CallbackT) -> "AutoPoster": ... - def on_error(self, callback: t.Any = None) -> t.Union[t.Any, 'AutoPoster']: + def on_error(self, callback: t.Any = None) -> t.Union[t.Any, "AutoPoster"]: """ Registers an autopost error callback. The callback can be either sync or async. @@ -146,7 +146,7 @@ def on_error(self, callback: t.Any = None) -> t.Union[t.Any, 'AutoPoster']: .. code-block:: python # The following are valid. - autoposter = client.autopost().on_error(lambda err: print(f'Error! {err!r}')) + autoposter = client.autopost().on_error(lambda err: print(f"Error! {err!r}")) # Used as decorator, the decorated function will become the AutoPoster object. @@ -180,9 +180,9 @@ def decorator(callback: CallbackT) -> CallbackT: def stats(self, callback: None) -> t.Callable[[StatsCallbackT], StatsCallbackT]: ... @t.overload - def stats(self, callback: StatsCallbackT) -> 'AutoPoster': ... + def stats(self, callback: StatsCallbackT) -> "AutoPoster": ... - def stats(self, callback: t.Any = None) -> t.Union[t.Any, 'AutoPoster']: + def stats(self, callback: t.Any = None) -> t.Union[t.Any, "AutoPoster"]: """ Registers a function that returns an instance of :class:`.StatsWrapper`. The callback can be either sync or async. @@ -237,7 +237,7 @@ def interval(self, seconds: t.Union[float, datetime.timedelta]) -> None: self.set_interval(seconds) - def set_interval(self, seconds: t.Union[float, datetime.timedelta]) -> 'AutoPoster': + def set_interval(self, seconds: t.Union[float, datetime.timedelta]) -> "AutoPoster": """ Sets the interval between posting stats. @@ -254,7 +254,7 @@ def set_interval(self, seconds: t.Union[float, datetime.timedelta]) -> 'AutoPost seconds = seconds.total_seconds() if seconds < 900: - raise ValueError('interval must be greater than 900 seconds.') + raise ValueError("interval must be greater than 900 seconds.") self._interval = seconds @@ -270,7 +270,7 @@ def _refresh_state(self) -> None: self._task = None self._stopping = False - def _fut_done_callback(self, future: 'asyncio.Future') -> None: + def _fut_done_callback(self, future: "asyncio.Future") -> None: self._refresh_state() if future.cancelled(): @@ -291,7 +291,7 @@ async def _internal_loop(self) -> None: if isinstance(err, errors.HTTPException) and err.code == 401: raise err from None else: - if on_success := getattr(self, '_success', None): + if on_success := getattr(self, "_success", None): await self.client._invoke_callback(on_success) if self._stopping: @@ -301,7 +301,7 @@ async def _internal_loop(self) -> None: finally: self._refresh_state() - def start(self) -> 'asyncio.Task[None]': + def start(self) -> "asyncio.Task[None]": """ Starts the autoposter loop. @@ -313,12 +313,12 @@ def start(self) -> 'asyncio.Task[None]': :rtype: :class:`~asyncio.Task`. """ - if not hasattr(self, '_stats'): + if not hasattr(self, "_stats"): raise errors.TopGGException( - 'You must provide a callback that returns the stats.' + "You must provide a callback that returns the stats." ) elif self.is_running: - raise errors.TopGGException('The autoposter is already running.') + raise errors.TopGGException("The autoposter is already running.") self._task = task = asyncio.ensure_future(self._internal_loop()) task.add_done_callback(self._fut_done_callback) diff --git a/topgg/client.py b/topgg/client.py index 04efbf4f..e71be24f 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -40,7 +40,7 @@ from .version import VERSION -BASE_URL = 'https://top.gg/api' +BASE_URL = "https://top.gg/api" MAXIMUM_DELAY_THRESHOLD = 5.0 @@ -76,14 +76,14 @@ class DBLClient(DataContainerMixin): """This project's ID.""" __slots__: tuple[str, ...] = ( - '__own_session', - '__session', - '__token', - '__ratelimiter', - '__ratelimiters', - '__current_ratelimit', - '_autopost', - 'id', + "__own_session", + "__session", + "__token", + "__ratelimiter", + "__ratelimiters", + "__current_ratelimit", + "_autopost", + "id", ) def __init__( @@ -92,16 +92,16 @@ def __init__( super().__init__() if not isinstance(token, str) or not token: - raise TypeError('An API token is required to use this API.') + raise TypeError("An API token is required to use this API.") - if kwargs.pop('default_bot_id', None): + if kwargs.pop("default_bot_id", None): warnings.warn( - 'The default bot ID is now derived from the Top.gg API token itself', + "The default bot ID is now derived from the Top.gg API token itself", DeprecationWarning, ) for key in kwargs.keys(): - warnings.warn(f'Ignored keyword argument: {key}', DeprecationWarning) + warnings.warn(f"Ignored keyword argument: {key}", DeprecationWarning) self._autopost = None self.__own_session = session is None @@ -111,15 +111,15 @@ def __init__( self.__token = token try: - encoded_json = token.split('.')[1] - encoded_json += '=' * (4 - (len(encoded_json) % 4)) + encoded_json = token.split(".")[1] + encoded_json += "=" * (4 - (len(encoded_json) % 4)) encoded_json = json.loads(b64decode(encoded_json)) - self.id = int(encoded_json['id']) + self.id = int(encoded_json["id"]) except (IndexError, ValueError, binascii.Error, json.decoder.JSONDecodeError): - raise ValueError('Got a malformed API token.') from None + raise ValueError("Got a malformed API token.") from None - endpoint_ratelimits = namedtuple('EndpointRatelimits', 'global_ bot') + endpoint_ratelimits = namedtuple("EndpointRatelimits", "global_ bot") self.__ratelimiter = endpoint_ratelimits( global_=Ratelimiter(99, 1), bot=Ratelimiter(59, 60) @@ -128,7 +128,7 @@ def __init__( self.__current_ratelimit = None def __repr__(self) -> str: - return f'<{__class__.__name__} {self.__session!r}>' + return f"<{__class__.__name__} {self.__session!r}>" def __int__(self) -> int: return self.id @@ -147,7 +147,7 @@ async def __request( body: Optional[dict] = None, ) -> dict: if self.is_closed: - raise errors.ClientStateException('Client session is already closed.') + raise errors.ClientStateException("Client session is already closed.") if self.__current_ratelimit is not None: current_time = time() @@ -159,17 +159,17 @@ async def __request( ratelimiter = ( self.__ratelimiters - if path.startswith('/bots') + if path.startswith("/bots") else self.__ratelimiter.global_ ) kwargs = {} if body: - kwargs['json'] = body + kwargs["json"] = body if params: - kwargs['params'] = params + kwargs["params"] = params status = None retry_after = None @@ -181,16 +181,16 @@ async def __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/', + "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 - retry_after = float(resp.headers.get('Retry-After', 0)) + retry_after = float(resp.headers.get("Retry-After", 0)) - if 'json' in resp.headers['Content-Type']: + if "json" in resp.headers["Content-Type"]: try: output = await resp.json() except json.decoder.JSONDecodeError: @@ -211,7 +211,7 @@ async def __request( return await self.__request(method, path) raise errors.HTTPException( - output and output.get('message', output.get('detail')), status + output and output.get("message", output.get("detail")), status ) from None async def get_bot_info(self, id: Optional[int]) -> types.BotData: @@ -235,7 +235,7 @@ async def get_bot_info(self, id: Optional[int]) -> types.BotData: :rtype: :class:`.BotData` """ - return types.BotData(await self.__request('GET', f'/bots/{id or self.id}')) + return types.BotData(await self.__request("GET", f"/bots/{id or self.id}")) async def get_bots( self, @@ -279,34 +279,34 @@ async def get_bots( params = {} if limit is not None: - params['limit'] = max(min(limit, 500), 1) + params["limit"] = max(min(limit, 500), 1) if offset is not None: - params['offset'] = max(min(offset, 499), 0) + params["offset"] = max(min(offset, 499), 0) if sort is not None: if not isinstance(sort, types.SortBy): if isinstance(sort, str) and sort in types.SortBy: warnings.warn( - 'The sort argument now expects a SortBy enum, not a str', + "The sort argument now expects a SortBy enum, not a str", DeprecationWarning, ) - params['sort'] = sort + params["sort"] = sort else: raise TypeError( - f'Expected sort to be a SortBy enum, got {sort.__class__.__name__}.' + f"Expected sort to be a SortBy enum, got {sort.__class__.__name__}." ) else: - params['sort'] = sort.value + params["sort"] = sort.value for arg in args: - warnings.warn(f'Ignored extra argument: {arg!r}', DeprecationWarning) + warnings.warn(f"Ignored extra argument: {arg!r}", DeprecationWarning) for key in kwargs.keys(): - warnings.warn(f'Ignored keyword argument: {key}', DeprecationWarning) + warnings.warn(f"Ignored keyword argument: {key}", DeprecationWarning) - return types.BotsData(await self.__request('GET', '/bots', params=params)) + return types.BotsData(await self.__request("GET", "/bots", params=params)) async def get_guild_count(self) -> Optional[types.BotStatsData]: """ @@ -327,7 +327,7 @@ async def get_guild_count(self) -> Optional[types.BotStatsData]: :rtype: Optional[:py:class:`.BotStatsData`] """ - stats = await self.__request('GET', '/bots/stats') + stats = await self.__request("GET", "/bots/stats") return stats and types.BotStatsData(stats) @@ -367,15 +367,15 @@ async def post_guild_count( """ for key in kwargs.keys(): - warnings.warn(f'Ignored keyword argument: {key}', DeprecationWarning) + warnings.warn(f"Ignored keyword argument: {key}", DeprecationWarning) if isinstance(stats, types.StatsWrapper): guild_count = stats.server_count if not guild_count or guild_count <= 0: - raise ValueError(f'Got an invalid server count. Got {guild_count!r}.') + raise ValueError(f"Got an invalid server count. Got {guild_count!r}.") - await self.__request('POST', '/bots/stats', body={'server_count': guild_count}) + await self.__request("POST", "/bots/stats", body={"server_count": guild_count}) async def get_weekend_status(self) -> bool: """ @@ -395,9 +395,9 @@ async def get_weekend_status(self) -> bool: :rtype: :py:class:`bool` """ - response = await self.__request('GET', '/weekend') + response = await self.__request("GET", "/weekend") - return response['is_weekend'] + return response["is_weekend"] async def get_bot_votes(self, page: int = 1) -> list[types.BriefUserData]: """ @@ -430,7 +430,7 @@ async def get_bot_votes(self, page: int = 1) -> list[types.BriefUserData]: return [ types.BriefUserData(data) for data in await self.__request( - 'GET', f'/bots/{self.id}/votes', params={'page': max(page, 1)} + "GET", f"/bots/{self.id}/votes", params={"page": max(page, 1)} ) ] @@ -443,9 +443,9 @@ async def get_user_info(self, user_id: int) -> types.UserData: """ - warnings.warn('get_user_info() is no longer supported', DeprecationWarning) + warnings.warn("get_user_info() is no longer supported", DeprecationWarning) - raise errors.HTTPException('User not found', 404) + raise errors.HTTPException("User not found", 404) async def get_user_vote(self, id: int) -> bool: """ @@ -468,9 +468,9 @@ async def get_user_vote(self, id: int) -> bool: :rtype: :py:class:`bool` """ - response = await self.__request('GET', '/bots/check', params={'userId': id}) + response = await self.__request("GET", "/bots/check", params={"userId": id}) - return bool(response['voted']) + return bool(response["voted"]) def autopost(self) -> AutoPoster: """ @@ -490,12 +490,12 @@ def get_stats() -> int: @autoposter.on_success def success() -> None: - print('Successfully posted statistics to the Top.gg API!') + print("Successfully posted statistics to the Top.gg API!") @autoposter.on_error def error(exc: Exception) -> None: - print(f'Error: {exc!r}') + print(f"Error: {exc!r}") autoposter.start() @@ -525,7 +525,7 @@ async def close(self) -> None: if self.__own_session and not self.is_closed: await self.__session.close() - async def __aenter__(self) -> 'DBLClient': + async def __aenter__(self) -> "DBLClient": return self async def __aexit__(self, *_, **__) -> None: diff --git a/topgg/data.py b/topgg/data.py index fe11c796..364cef11 100644 --- a/topgg/data.py +++ b/topgg/data.py @@ -29,8 +29,8 @@ from .errors import TopGGException -T = t.TypeVar('T') -DataContainerT = t.TypeVar('DataContainerT', bound='DataContainerMixin') +T = t.TypeVar("T") +DataContainerT = t.TypeVar("DataContainerT", bound="DataContainerMixin") def data(type_: t.Type[T]) -> T: @@ -39,7 +39,7 @@ def data(type_: t.Type[T]) -> T: .. code-block:: python - client = topgg.DBLClient(os.getenv('BOT_TOKEN')).set_data(bot) + client = topgg.DBLClient(os.getenv("BOT_TOKEN")).set_data(bot) autoposter = client.autopost() @@ -58,7 +58,7 @@ def get_stats(bot: MyBot = topgg.data(MyBot)) -> topgg.StatsWrapper: class Data(t.Generic[T]): - __slots__: tuple[str, ...] = ('type',) + __slots__: tuple[str, ...] = ("type",) def __init__(self, type_: t.Type[T]) -> None: self.type = type_ @@ -71,7 +71,7 @@ class DataContainerMixin: This is useful for injecting some data so that they're available as arguments in your functions. """ - __slots__: tuple[str, ...] = ('_data',) + __slots__: tuple[str, ...] = ("_data",) def __init__(self) -> None: self._data = {type(self): self} @@ -97,7 +97,7 @@ def set_data( if not override and type_ in self._data: raise TopGGException( - f'{type_} already exists. If you wish to override it, pass True into the override parameter.' + f"{type_} already exists. If you wish to override it, pass True into the override parameter." ) self._data[type_] = data_ diff --git a/topgg/errors.py b/topgg/errors.py index ce563ceb..88512e96 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -47,7 +47,7 @@ class ClientStateException(ClientException): class HTTPException(TopGGException): """HTTP request failure. Extends :class:`.TopGGException`.""" - __slots__: tuple[str, ...] = ('message', 'code') + __slots__: tuple[str, ...] = ("message", "code") message: Optional[str] """The message returned from the API.""" @@ -59,27 +59,27 @@ def __init__(self, message: Optional[str], code: Optional[int]): self.message = message self.code = code - super().__init__(f'Got {code}: {message!r}') + super().__init__(f"Got {code}: {message!r}") def __repr__(self) -> str: - return f'<{__class__.__name__} message={self.message!r} code={self.code}>' + return f"<{__class__.__name__} message={self.message!r} code={self.code}>" class Ratelimited(HTTPException): """Ratelimited from sending more requests. Extends :class:`.HTTPException`.""" - __slots__: tuple[str, ...] = ('retry_after',) + __slots__: tuple[str, ...] = ("retry_after",) retry_after: float """How long the client should wait in seconds before it could send requests again without receiving a 429.""" def __init__(self, retry_after: float): super().__init__( - f'Blocked from sending more requests, try again in {retry_after} seconds.', + f"Blocked from sending more requests, try again in {retry_after} seconds.", 429, ) self.retry_after = retry_after def __repr__(self) -> str: - return f'<{__class__.__name__} retry_after={self.retry_after}>' + return f"<{__class__.__name__} retry_after={self.retry_after}>" diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index 8d5d635a..ec213e92 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -36,7 +36,7 @@ class Ratelimiter: """Handles ratelimits for a specific endpoint.""" - __slots__: tuple[str, ...] = ('__lock', '__max_calls', '__period', '__calls') + __slots__: tuple[str, ...] = ("__lock", "__max_calls", "__period", "__calls") def __init__( self, @@ -48,7 +48,7 @@ def __init__( self.__max_calls = max_calls self.__lock = asyncio.Lock() - async def __aenter__(self) -> 'Ratelimiter': + async def __aenter__(self) -> "Ratelimiter": """Delays the request to this endpoint if it could lead to a ratelimit.""" async with self.__lock: @@ -64,7 +64,7 @@ async def __aexit__( self, _exc_type: type[BaseException], _exc_val: BaseException, - _exc_tb: 'TracebackType', + _exc_tb: "TracebackType", ) -> None: """Stores the previous request's timestamp.""" @@ -84,12 +84,12 @@ def _timespan(self) -> float: class Ratelimiters: """Handles ratelimits for multiple endpoints.""" - __slots__: tuple[str, ...] = ('__ratelimiters',) + __slots__: tuple[str, ...] = ("__ratelimiters",) def __init__(self, ratelimiters: Iterable[Ratelimiter]): self.__ratelimiters = ratelimiters - async def __aenter__(self) -> 'Ratelimiters': + async def __aenter__(self) -> "Ratelimiters": """Delays the request to this endpoint if it could lead to a ratelimit.""" for ratelimiter in self.__ratelimiters: @@ -101,7 +101,7 @@ async def __aexit__( self, exc_type: type[BaseException], exc_val: BaseException, - exc_tb: 'TracebackType', + exc_tb: "TracebackType", ) -> None: """Stores the previous request's timestamp.""" diff --git a/topgg/types.py b/topgg/types.py index 3d171fc0..c211d27e 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -32,7 +32,7 @@ from enum import Enum -T = t.TypeVar('T') +T = t.TypeVar("T") def truthy_only(value: t.Optional[T]) -> t.Optional[T]: @@ -45,8 +45,8 @@ class WidgetProjectType(Enum): __slots__: tuple[str, ...] = () - DISCORD_BOT = 'discord/bot' - DISCORD_SERVER = 'discord/server' + DISCORD_BOT = "discord/bot" + DISCORD_SERVER = "discord/server" class WidgetType(Enum): @@ -54,16 +54,16 @@ class WidgetType(Enum): __slots__: tuple[str, ...] = () - LARGE = 'large' - VOTES = 'votes' - OWNER = 'owner' - SOCIAL = 'social' + LARGE = "large" + VOTES = "votes" + OWNER = "owner" + SOCIAL = "social" class WidgetOptions: """Top.gg widget creation options.""" - __slots__: tuple[str, ...] = ('id', 'project_type', 'type') + __slots__: tuple[str, ...] = ("id", "project_type", "type") id: int """This widget's project ID.""" @@ -87,44 +87,44 @@ def __init__( self.type = type for arg in args: - warnings.warn(f'Ignored extra argument: {arg!r}', DeprecationWarning) + warnings.warn(f"Ignored extra argument: {arg!r}", DeprecationWarning) for key in kwargs.keys(): - warnings.warn(f'Ignored keyword argument: {key}', DeprecationWarning) + warnings.warn(f"Ignored keyword argument: {key}", DeprecationWarning) def __repr__(self) -> str: - return f'<{__class__.__name__} id={self.id} project_type={self.project_type!r} type={self.type!r}>' + return f"<{__class__.__name__} id={self.id} project_type={self.project_type!r} type={self.type!r}>" class BotData: """A Discord bot listed on Top.gg.""" __slots__: tuple[str, ...] = ( - 'id', - 'topgg_id', - 'username', - 'discriminator', - 'avatar', - 'def_avatar', - 'prefix', - 'shortdesc', - 'longdesc', - 'tags', - 'website', - 'support', - 'github', - 'owners', - 'guilds', - 'invite', - 'date', - 'certified_bot', - 'vanity', - 'points', - 'monthly_points', - 'donatebotguildid', - 'server_count', - 'review_score', - 'review_count', + "id", + "topgg_id", + "username", + "discriminator", + "avatar", + "def_avatar", + "prefix", + "shortdesc", + "longdesc", + "tags", + "website", + "support", + "github", + "owners", + "guilds", + "invite", + "date", + "certified_bot", + "vanity", + "points", + "monthly_points", + "donatebotguildid", + "server_count", + "review_score", + "review_count", ) id: int @@ -203,39 +203,39 @@ class BotData: """This bot's review count.""" def __init__(self, json: dict): - self.id = int(json['clientid']) - self.topgg_id = int(json['id']) - self.username = json['username'] - self.discriminator = '0' - self.avatar = json['avatar'] - self.def_avatar = '' - self.prefix = json['prefix'] - self.shortdesc = json['shortdesc'] - self.longdesc = truthy_only(json.get('longdesc')) - self.tags = json['tags'] - self.website = truthy_only(json.get('website')) - self.support = truthy_only(json.get('support')) - self.github = truthy_only(json.get('github')) - self.owners = [int(id) for id in json['owners']] + self.id = int(json["clientid"]) + self.topgg_id = int(json["id"]) + self.username = json["username"] + self.discriminator = "0" + self.avatar = json["avatar"] + self.def_avatar = "" + self.prefix = json["prefix"] + self.shortdesc = json["shortdesc"] + self.longdesc = truthy_only(json.get("longdesc")) + self.tags = json["tags"] + self.website = truthy_only(json.get("website")) + self.support = truthy_only(json.get("support")) + self.github = truthy_only(json.get("github")) + self.owners = [int(id) for id in json["owners"]] self.guilds = [] - self.invite = truthy_only(json.get('invite')) - self.date = datetime.fromisoformat(json['date'].replace('Z', '+00:00')) + self.invite = truthy_only(json.get("invite")) + self.date = datetime.fromisoformat(json["date"].replace("Z", "+00:00")) self.certified_bot = False - self.vanity = truthy_only(json.get('vanity')) - self.points = json['points'] - self.monthly_points = json['monthlyPoints'] + self.vanity = truthy_only(json.get("vanity")) + self.points = json["points"] + self.monthly_points = json["monthlyPoints"] self.donatebotguildid = 0 - self.server_count = json.get('server_count') - self.review_score = json['reviews']['averageScore'] - self.review_count = json['reviews']['count'] + self.server_count = json.get("server_count") + self.review_score = json["reviews"]["averageScore"] + self.review_count = json["reviews"]["count"] def __repr__(self) -> str: - return f'<{__class__.__name__} id={self.id} username={self.username!r} points={self.points} monthly_points={self.monthly_points} server_count={self.server_count}>' + return f"<{__class__.__name__} id={self.id} username={self.username!r} points={self.points} monthly_points={self.monthly_points} server_count={self.server_count}>" def __int__(self) -> int: return self.id - def __eq__(self, other: 'BotData') -> bool: + def __eq__(self, other: "BotData") -> bool: if isinstance(other, __class__): return self.id == other.id @@ -245,7 +245,7 @@ def __eq__(self, other: 'BotData') -> bool: class BotsData: """A list of Discord bot's listed on Top.gg.""" - __slots__: tuple[str, ...] = ('results', 'limit', 'offset', 'count', 'total') + __slots__: tuple[str, ...] = ("results", "limit", "offset", "count", "total") results: list[BotData] """The list of bots returned.""" @@ -263,14 +263,14 @@ class BotsData: """The amount of bots that matches the specified query. May be equal or greater than count or len(results).""" def __init__(self, json: dict): - self.results = [BotData(bot) for bot in json['results']] - self.limit = json['limit'] - self.offset = json['offset'] - self.count = json['count'] - self.total = json['total'] + self.results = [BotData(bot) for bot in json["results"]] + self.limit = json["limit"] + self.offset = json["offset"] + self.count = json["count"] + self.total = json["total"] def __repr__(self) -> str: - return f'<{__class__.__name__} results={self.results!r} count={self.count} total={self.total}>' + return f"<{__class__.__name__} results={self.results!r} count={self.count} total={self.total}>" def __iter__(self) -> t.Iterable[BotData]: return iter(self.results) @@ -282,7 +282,7 @@ def __len__(self) -> int: class BotStatsData: """A Discord bot's statistics.""" - __slots__: tuple[str, ...] = ('server_count', 'shards', 'shard_count') + __slots__: tuple[str, ...] = ("server_count", "shards", "shard_count") server_count: t.Optional[int] """The amount of servers the bot is in.""" @@ -294,17 +294,17 @@ class BotStatsData: """The amount of shards the bot has.""" def __init__(self, json: dict): - self.server_count = json.get('server_count') + self.server_count = json.get("server_count") self.shards = [] self.shard_count = None def __repr__(self) -> str: - return f'<{__class__.__name__} server_count={self.server_count}>' + return f"<{__class__.__name__} server_count={self.server_count}>" def __int__(self) -> int: return self.server_count - def __eq__(self, other: 'BotStatsData') -> bool: + def __eq__(self, other: "BotStatsData") -> bool: if isinstance(other, __class__): return self.server_count == other.server_count @@ -314,7 +314,7 @@ def __eq__(self, other: 'BotStatsData') -> bool: class BriefUserData: """A Top.gg user's brief information.""" - __slots__: tuple[str, ...] = ('id', 'username', 'avatar') + __slots__: tuple[str, ...] = ("id", "username", "avatar") id: int """This user's ID.""" @@ -326,17 +326,17 @@ class BriefUserData: """This user's avatar URL.""" def __init__(self, json: dict): - self.id = int(json['id']) - self.username = json['username'] - self.avatar = json['avatar'] + self.id = int(json["id"]) + self.username = json["username"] + self.avatar = json["avatar"] def __repr__(self) -> str: - return f'<{__class__.__name__} id={self.id} username={self.username!r}>' + return f"<{__class__.__name__} id={self.id} username={self.username!r}>" def __int__(self) -> int: return self.id - def __eq__(self, other: 'BriefUserData') -> bool: + def __eq__(self, other: "BriefUserData") -> bool: if isinstance(other, __class__): return self.id == other.id @@ -346,7 +346,7 @@ def __eq__(self, other: 'BriefUserData') -> bool: class SocialData: """A Top.gg user's socials.""" - __slots__: tuple[str, ...] = ('youtube', 'reddit', 'twitter', 'instagram', 'github') + __slots__: tuple[str, ...] = ("youtube", "reddit", "twitter", "instagram", "github") youtube: str """This user's YouTube channel.""" @@ -368,16 +368,16 @@ class UserData: """A Top.gg user.""" __slots__: tuple[str, ...] = ( - 'id', - 'username', - 'discriminator', - 'social', - 'color', - 'supporter', - 'certified_dev', - 'mod', - 'web_mod', - 'admin', + "id", + "username", + "discriminator", + "social", + "color", + "supporter", + "certified_dev", + "mod", + "web_mod", + "admin", ) id: int @@ -416,20 +416,20 @@ class SortBy(Enum): __slots__: tuple[str, ...] = () - ID = 'id' + ID = "id" """Sorts results based on each bot's ID.""" - SUBMISSION_DATE = 'date' + SUBMISSION_DATE = "date" """Sorts results based on each bot's submission date.""" - MONTHLY_VOTES = 'monthlyPoints' + MONTHLY_VOTES = "monthlyPoints" """Sorts results based on each bot's monthly vote count.""" class VoteDataDict: """A dispatched Top.gg project vote event.""" - __slots__: tuple[str, ...] = ('type', 'user', 'query') + __slots__: tuple[str, ...] = ("type", "user", "query") type: t.Optional[str] """Vote event type. ``upvote`` (invoked from the vote page by a user) or ``test`` (invoked explicitly by the developer for testing.)""" @@ -441,21 +441,21 @@ class VoteDataDict: """Query strings found on the vote page.""" def __init__(self, json: dict): - self.type = json.get('type') + self.type = json.get("type") - user = json.get('user') + user = json.get("user") self.user = user and int(user) - self.query = parse_qs(json.get('query', '')) + self.query = parse_qs(json.get("query", "")) def __repr__(self) -> str: - return f'<{__class__.__name__} type={self.type!r} user={self.user} query={self.query!r}>' + return f"<{__class__.__name__} type={self.type!r} user={self.user} query={self.query!r}>" class BotVoteData(VoteDataDict): """A dispatched Top.gg Discord bot vote event. Extends :class:`.VoteDataDict`.""" - __slots__: tuple[str, ...] = ('bot', 'is_weekend') + __slots__: tuple[str, ...] = ("bot", "is_weekend") bot: t.Optional[int] """The ID of the bot that received a vote.""" @@ -466,19 +466,19 @@ class BotVoteData(VoteDataDict): def __init__(self, json: dict): super().__init__(json) - bot = json.get('bot') + bot = json.get("bot") self.bot = bot and int(bot) - self.is_weekend = json.get('isWeekend', False) + self.is_weekend = json.get("isWeekend", False) def __repr__(self) -> str: - return f'<{__class__.__name__} type={self.type!r} user={self.user} is_weekend={self.is_weekend}>' + return f"<{__class__.__name__} type={self.type!r} user={self.user} is_weekend={self.is_weekend}>" class GuildVoteData(VoteDataDict): """ "A dispatched Top.gg Discord server vote event. Extends :class:`.VoteDataDict`.""" - __slots__: tuple[str, ...] = ('guild',) + __slots__: tuple[str, ...] = ("guild",) guild: t.Optional[int] """The ID of the server that received a vote.""" @@ -486,7 +486,7 @@ class GuildVoteData(VoteDataDict): def __init__(self, json: dict): super().__init__(json) - guild = json.get('guild') + guild = json.get("guild") self.guild = guild and int(guild) diff --git a/topgg/version.py b/topgg/version.py index 84262d36..177b9352 100644 --- a/topgg/version.py +++ b/topgg/version.py @@ -1 +1 @@ -VERSION = '1.5.0' +VERSION = "1.5.0" diff --git a/topgg/webhook.py b/topgg/webhook.py index 7340ae9c..6e4cb16a 100644 --- a/topgg/webhook.py +++ b/topgg/webhook.py @@ -34,8 +34,8 @@ if t.TYPE_CHECKING: from aiohttp.web import Request, StreamResponse -T = t.TypeVar('T', bound='WebhookEndpoint') -_HandlerT = t.Callable[['Request'], t.Awaitable['StreamResponse']] +T = t.TypeVar("T", bound="WebhookEndpoint") +_HandlerT = t.Callable[["Request"], t.Awaitable["StreamResponse"]] class WebhookType(enum.Enum): @@ -53,7 +53,7 @@ class WebhookType(enum.Enum): class WebhookManager(DataContainerMixin): """A Top.gg webhook manager.""" - __slots__: tuple[str, ...] = ('__app', '_webserver', '_is_running') + __slots__: tuple[str, ...] = ("__app", "_webserver", "_is_running") def __init__(self) -> None: super().__init__() @@ -62,17 +62,17 @@ def __init__(self) -> None: self._is_running = False def __repr__(self) -> str: - return f'<{__class__.__name__} is_running={self.is_running}>' + return f"<{__class__.__name__} is_running={self.is_running}>" @t.overload - def endpoint(self, endpoint_: None = None) -> 'BoundWebhookEndpoint': ... + def endpoint(self, endpoint_: None = None) -> "BoundWebhookEndpoint": ... @t.overload - def endpoint(self, endpoint_: 'WebhookEndpoint') -> 'WebhookManager': ... + def endpoint(self, endpoint_: "WebhookEndpoint") -> "WebhookManager": ... def endpoint( - self, endpoint_: t.Optional['WebhookEndpoint'] = None - ) -> t.Union['WebhookManager', 'BoundWebhookEndpoint']: + self, endpoint_: t.Optional["WebhookEndpoint"] = None + ) -> t.Union["WebhookManager", "BoundWebhookEndpoint"]: """ A helper method that returns a :class:`.WebhookEndpoint` object. @@ -86,14 +86,14 @@ def endpoint( """ if endpoint_: - if not hasattr(endpoint_, '_callback'): - raise TopGGException('endpoint missing callback.') + if not hasattr(endpoint_, "_callback"): + raise TopGGException("endpoint missing callback.") - if not hasattr(endpoint_, '_type'): - raise TopGGException('endpoint missing type.') + if not hasattr(endpoint_, "_type"): + raise TopGGException("endpoint missing type.") - if not hasattr(endpoint_, '_route'): - raise TopGGException('endpoint missing route.') + if not hasattr(endpoint_, "_route"): + raise TopGGException("endpoint missing route.") self.app.router.add_post( endpoint_._route, @@ -117,7 +117,7 @@ async def start(self, port: int) -> None: runner = web.AppRunner(self.__app) await runner.setup() - self._webserver = web.TCPSite(runner, '0.0.0.0', port) + self._webserver = web.TCPSite(runner, "0.0.0.0", port) await self._webserver.start() self._is_running = True @@ -144,8 +144,8 @@ def _get_handler( self, type_: WebhookType, auth: str, callback: t.Callable[..., t.Any] ) -> _HandlerT: async def _handler(request: web.Request) -> web.Response: - if request.headers.get('Authorization', '') != auth: - return web.Response(status=401, text='Unauthorized') + if request.headers.get("Authorization", "") != auth: + return web.Response(status=401, text="Unauthorized") data = await request.json() @@ -154,7 +154,7 @@ async def _handler(request: web.Request) -> web.Response: (BotVoteData if type_ is WebhookType.BOT else GuildVoteData)(data), ) - return web.Response(status=204, text='') + return web.Response(status=204, text="") return _handler @@ -165,16 +165,16 @@ async def _handler(request: web.Request) -> web.Response: class WebhookEndpoint: """A helper class to setup a Top.gg webhook endpoint.""" - __slots__: tuple[str, ...] = ('_callback', '_auth', '_route', '_type') + __slots__: tuple[str, ...] = ("_callback", "_auth", "_route", "_type") def __init__(self) -> None: - self._auth = '' + self._auth = "" def __call__(self, *args: t.Any, **kwargs: t.Any) -> t.Any: return self._callback(*args, **kwargs) def __repr__(self) -> str: - return f'<{__class__.__name__} type={self._type!r} route={self._route!r}>' + return f"<{__class__.__name__} type={self._type!r} route={self._route!r}>" def type(self: T, type_: WebhookType) -> T: """ @@ -227,7 +227,7 @@ def callback(self, callback_: None) -> t.Callable[[CallbackT], CallbackT]: ... @t.overload def callback(self: T, callback_: CallbackT) -> T: ... - def callback(self, callback_: t.Any = None) -> t.Union[t.Any, 'WebhookEndpoint']: + def callback(self, callback_: t.Any = None) -> t.Union[t.Any, "WebhookEndpoint"]: """ Registers a vote callback that gets called whenever this endpoint receives POST requests. The callback can be either sync or async. @@ -240,12 +240,12 @@ def callback(self, callback_: t.Any = None) -> t.Union[t.Any, 'WebhookEndpoint'] endpoint = ( topgg.WebhookEndpoint() .type(topgg.WebhookType.BOT) - .route('/dblwebhook') - .auth('youshallnotpass') + .route("/dblwebhook") + .auth("youshallnotpass") ) # The following are valid. - endpoint.callback(lambda vote: print(f'Got a vote: {vote!r}')) + endpoint.callback(lambda vote: print(f"Got a vote: {vote!r}")) # Used as decorator, the decorated function will become the WebhookEndpoint object. @@ -289,12 +289,12 @@ class BoundWebhookEndpoint(WebhookEndpoint): topgg.WebhookManager() .endpoint() .type(topgg.WebhookType.BOT) - .route('/dblwebhook') - .auth('youshallnotpass') + .route("/dblwebhook") + .auth("youshallnotpass") ) # The following are valid. - endpoint.callback(lambda vote: print(f'Got a vote: {vote!r}')) + endpoint.callback(lambda vote: print(f"Got a vote: {vote!r}")) # Used as decorator, the decorated function will become the BoundWebhookEndpoint object. @@ -315,7 +315,7 @@ def on_vote(vote: topgg.BotVoteData) -> None: ... :type manager: :class:`.WebhookManager` """ - __slots__: tuple[str, ...] = ('manager',) + __slots__: tuple[str, ...] = ("manager",) def __init__(self, manager: WebhookManager): super().__init__() @@ -323,7 +323,7 @@ def __init__(self, manager: WebhookManager): self.manager = manager def __repr__(self) -> str: - return f'<{__class__.__name__} manager={self.manager!r}>' + return f"<{__class__.__name__} manager={self.manager!r}>" def add_to_manager(self) -> WebhookManager: """ @@ -341,7 +341,7 @@ def add_to_manager(self) -> WebhookManager: def endpoint( - route: str, type: WebhookType, auth: str = '' + route: str, type: WebhookType, auth: str = "" ) -> t.Callable[[t.Callable[..., t.Any]], WebhookEndpoint]: """ A decorator factory for instantiating a :class:`.WebhookEndpoint`. @@ -351,7 +351,7 @@ def endpoint( manager = topgg.WebhookManager() - @topgg.endpoint('/dblwebhook', WebhookType.BOT, 'youshallnotpass') + @topgg.endpoint("/dblwebhook", WebhookType.BOT, "youshallnotpass") async def on_vote(vote: topgg.BotVoteData): ... From 26b95a9ddf4e6a584c4cae812008e5c81ae6bd4f Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 21 Oct 2025 16:11:01 +0700 Subject: [PATCH 16/19] revert: move changes done to tests to another pull request --- .github/workflows/python-package.yml | 41 ++++++ .github/workflows/python-publish.yml | 31 +++++ .github/workflows/release.yml | 21 --- .github/workflows/test.yml | 22 --- MANIFEST.in | 14 +- tests/test_autopost.py | 92 ------------- tests/test_client.py | 192 ++++++++++++++++++++++----- tests/test_data_container.py | 57 -------- tests/test_ratelimiter.py | 12 +- tests/test_type.py | 157 ++++++++++++++++------ tests/test_version.py | 7 + tests/test_webhook.py | 88 ++++++------ 12 files changed, 397 insertions(+), 337 deletions(-) create mode 100644 .github/workflows/python-package.yml create mode 100644 .github/workflows/python-publish.yml delete mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/test.yml delete mode 100644 tests/test_autopost.py delete mode 100644 tests/test_data_container.py create mode 100644 tests/test_version.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 00000000..60b37a8f --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,41 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Test Python package + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ 3.6, 3.7, 3.8, 3.9 ] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install testing dependencies + uses: py-actions/py-dependency-install@v2 + with: + path: "requirements-dev.txt" + - name: Install itself + run: | + python setup.py install + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics + - name: Test with pytest + run: | + pytest diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 00000000..1eba4d89 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,31 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +name: Upload Python Package + +on: + release: + types: [ created ] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.7' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine -r requirements.txt + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 1284d783..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Publish -on: - release: - types: [created] -jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-python@v6 - with: - python-version: 3.14 - - name: Install dependencies - run: python3 -m pip install build twine - - name: Build and publish - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} - run: | - python3 -m build - python3 -m twine upload dist/* \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 784f0b5e..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Test -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [ '3.10', 3.11, 3.12, 3.13, 3.14 ] - steps: - - uses: actions/checkout@v5 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - name: Install - run: python -m pip install . pytest mock pytest-mock pytest-asyncio - - name: Test with pytest - run: pytest diff --git a/MANIFEST.in b/MANIFEST.in index 3bd177d4..dc068afa 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,11 +1,3 @@ -prune .github -prune .ruff_cache -prune docs -prune examples -prune tests -exclude .gitignore -exclude .readthedocs.yml -exclude ruff.toml -exclude LICENSE -exclude ISSUE_TEMPLATE.md -exclude PULL_REQUEST_TEMPLATE.md \ No newline at end of file +include LICENSE +include requirements.txt +include README.rst diff --git a/tests/test_autopost.py b/tests/test_autopost.py deleted file mode 100644 index aee465de..00000000 --- a/tests/test_autopost.py +++ /dev/null @@ -1,92 +0,0 @@ -import datetime - -import mock -import pytest -from aiohttp import ClientSession -from pytest_mock import MockerFixture - -from topgg import DBLClient -from topgg.autopost import AutoPoster -from topgg.errors import HTTPException, TopGGException - - -MOCK_TOKEN = ".eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=." - - -@pytest.fixture -def session() -> ClientSession: - return mock.Mock(ClientSession) - - -@pytest.fixture -def autopost(session: ClientSession) -> AutoPoster: - return AutoPoster(DBLClient(MOCK_TOKEN, session=session)) - - -@pytest.mark.asyncio -async def test_AutoPoster_breaks_autopost_loop_on_401( - mocker: MockerFixture, session: ClientSession -) -> None: - mocker.patch( - "topgg.DBLClient.post_guild_count", - side_effect=HTTPException("Unauthorized", 401), - ) - - callback = mock.Mock() - autopost = DBLClient(MOCK_TOKEN, session=session).autopost().stats(callback) - - assert isinstance(autopost, AutoPoster) - assert not isinstance(autopost.stats()(callback), AutoPoster) - - autopost._interval = 1 - - with pytest.raises(HTTPException): - await autopost.start() - - callback.assert_called_once() - assert not autopost.is_running - - -@pytest.mark.asyncio -async def test_AutoPoster_raises_missing_stats(autopost: AutoPoster) -> None: - with pytest.raises( - TopGGException, match="You must provide a callback that returns the stats." - ): - await autopost.start() - - -@pytest.mark.asyncio -async def test_AutoPoster_raises_already_running(autopost: AutoPoster) -> None: - autopost.stats(mock.Mock()).start() - with pytest.raises(TopGGException, match="The autoposter is already running."): - await autopost.start() - - -@pytest.mark.asyncio -async def test_AutoPoster_interval_too_short(autopost: AutoPoster) -> None: - with pytest.raises(ValueError, match="interval must be greater than 900 seconds."): - autopost.set_interval(50) - - -@pytest.mark.asyncio -async def test_AutoPoster_error_callback( - mocker: MockerFixture, autopost: AutoPoster -) -> None: - error_callback = mock.Mock() - side_effect = HTTPException("Internal Server Error", 500) - - mocker.patch("topgg.DBLClient.post_guild_count", side_effect=side_effect) - task = autopost.on_error(error_callback).stats(mock.Mock()).start() - autopost.stop() - await task - error_callback.assert_called_once_with(side_effect) - - -def test_AutoPoster_interval(autopost: AutoPoster): - assert autopost.interval == 900 - autopost.set_interval(datetime.timedelta(hours=1)) - assert autopost.interval == 3600 - autopost.interval = datetime.timedelta(hours=2) - assert autopost.interval == 7200 - autopost.interval = 3600 - assert autopost.interval == 3600 diff --git a/tests/test_client.py b/tests/test_client.py index f26895a0..b29f8ded 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,11 +1,25 @@ +import asyncio +from typing import Optional + import mock import pytest from aiohttp import ClientSession +from discord import Client +from discord.ext.commands import Bot +from pytest import CaptureFixture +from pytest_mock import MockerFixture -import topgg +from topgg import DBLClient +from topgg.errors import ClientException, Unauthorized, UnauthorizedDetected -MOCK_TOKEN = ".eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=." +@pytest.fixture +def bot() -> Client: + bot = mock.Mock(Client) + bot.loop = asyncio.get_event_loop() + bot.guilds = [] + bot.is_closed.return_value = False + return bot @pytest.fixture @@ -14,52 +28,162 @@ def session() -> ClientSession: @pytest.fixture -def client(session: ClientSession) -> topgg.DBLClient: - return topgg.DBLClient(MOCK_TOKEN, session=session) +def exc() -> Exception: + return Exception("Test Exception") + + +@pytest.mark.parametrize( + "autopost, post_shard_count, autopost_interval", + [ + (True, True, 900), + (True, False, 900), + (True, True, None), + (True, False, None), + (False, False, None), + (False, False, 0), + ], +) +@pytest.mark.asyncio +async def test_DBLClient_validates_constructor_and_passes_for_valid_values( + bot: Client, + mocker: MockerFixture, + autopost: bool, + post_shard_count: bool, + autopost_interval: Optional[int], + session: ClientSession, +) -> None: + mocker.patch("topgg.DBLClient._auto_post", new_callable=mock.AsyncMock) # type: ignore + DBLClient( + bot, + "", + session=session, + autopost=autopost, + post_shard_count=post_shard_count, + autopost_interval=autopost_interval, + ) -@pytest.mark.asyncio -async def test_DBLClient_post_guild_count_with_no_args(client: topgg.DBLClient): - with pytest.raises(ValueError, match="Got an invalid server count. Got None."): - await client.post_guild_count() +@pytest.mark.parametrize( + "autopost, post_shard_count, autopost_interval", + [ + (True, True, 0), + (True, False, 500), + (False, True, 0), + (False, True, 900), + (False, True, None), + (False, False, 1800), + ], +) +def test_DBLClient_validates_constructor_and_fails_for_invalid_values( + bot: Client, + mocker: MockerFixture, + autopost: bool, + post_shard_count: bool, + autopost_interval: Optional[int], + session: ClientSession, +) -> None: + with pytest.raises(ClientException): + DBLClient( + bot, + "", + session=session, + autopost=autopost, + post_shard_count=post_shard_count, + autopost_interval=autopost_interval, + ) @pytest.mark.asyncio -async def test_DBLClient_get_weekend_status(monkeypatch, client: topgg.DBLClient): - monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock()) - await client.get_weekend_status() - client._DBLClient__request.assert_called_once() +async def test_DBLClient_breaks_autopost_loop_on_401( + bot: Client, mocker: MockerFixture, session: ClientSession +) -> None: + response = mock.Mock("reason, status") + response.reason = "Unauthorized" + response.status = 401 + + mocker.patch( + "topgg.DBLClient.post_guild_count", side_effect=Unauthorized(response, {}) + ) + mocker.patch( + "topgg.DBLClient._ensure_bot_user", + new_callable=mock.AsyncMock, # type: ignore + ) + + obj = DBLClient(bot, "", False, session=session) + + with pytest.raises(Unauthorized): + await obj._auto_post() @pytest.mark.asyncio -async def test_DBLClient_post_guild_count(monkeypatch, client: topgg.DBLClient): - monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock()) - await client.post_guild_count(guild_count=123) - client._DBLClient__request.assert_called_once() +@pytest.mark.parametrize( + "token", + [None, ""], + # treat None as str to suppress mypy +) +async def test_HTTPClient_fails_for_no_token( + bot: Client, token: str, session: ClientSession +) -> None: + with pytest.raises(UnauthorizedDetected): + await DBLClient(bot=bot, token=token, session=session).post_guild_count() @pytest.mark.asyncio -async def test_DBLClient_get_guild_count(monkeypatch, client: topgg.DBLClient): - monkeypatch.setattr( - "topgg.DBLClient._DBLClient__request", mock.AsyncMock(return_value={}) - ) - await client.get_guild_count() - client._DBLClient__request.assert_called_once() +async def test_Client_with_default_autopost_error_handler( + mocker: MockerFixture, + capsys: CaptureFixture[str], + session: ClientSession, + exc: Exception, +) -> None: + client = Client() + mocker.patch("topgg.DBLClient._auto_post", new_callable=mock.AsyncMock) # type: ignore + dbl = DBLClient(client, "", True, session=session) + assert client.on_autopost_error == dbl.on_autopost_error + await client.on_autopost_error(exc) + assert "Ignoring exception in auto post loop" in capsys.readouterr().err @pytest.mark.asyncio -async def test_DBLClient_get_bot_votes(monkeypatch, client: topgg.DBLClient): - monkeypatch.setattr( - "topgg.DBLClient._DBLClient__request", mock.AsyncMock(return_value=[]) - ) - await client.get_bot_votes() - client._DBLClient__request.assert_called_once() +async def test_Client_with_custom_autopost_error_handler( + mocker: MockerFixture, session: ClientSession, exc: Exception +) -> None: + client = Client() + state = False + + @client.event + async def on_autopost_error(exc: Exception) -> None: + nonlocal state + state = True + + mocker.patch("topgg.DBLClient._auto_post", new_callable=mock.AsyncMock) # type: ignore + DBLClient(client, "", True, session=session) + await client.on_autopost_error(exc) + assert state @pytest.mark.asyncio -async def test_DBLClient_get_user_vote(monkeypatch, client: topgg.DBLClient): - monkeypatch.setattr( - "topgg.DBLClient._DBLClient__request", mock.AsyncMock(return_value={"voted": 1}) - ) - await client.get_user_vote(1234) - client._DBLClient__request.assert_called_once() +async def test_Bot_with_autopost_error_listener( + mocker: MockerFixture, + capsys: CaptureFixture[str], + session: ClientSession, + exc: Exception, +) -> None: + bot = Bot("") + state = False + + @bot.listen() + async def on_autopost_error(exc: Exception) -> None: + nonlocal state + state = True + + mocker.patch("topgg.DBLClient._auto_post", new_callable=mock.AsyncMock) # type: ignore + DBLClient(bot, "", True, session=session) + + # await to make sure all the listeners were run before asserting + # as .dispatch schedules the events and will continue + # to the assert line without finishing the event callbacks + await bot.on_autopost_error(exc) + await on_autopost_error(exc) + + assert "Ignoring exception in auto post loop" not in capsys.readouterr().err + assert state diff --git a/tests/test_data_container.py b/tests/test_data_container.py deleted file mode 100644 index 0fd1bede..00000000 --- a/tests/test_data_container.py +++ /dev/null @@ -1,57 +0,0 @@ -import pytest - -from topgg.data import DataContainerMixin, data -from topgg.errors import TopGGException - - -@pytest.fixture -def data_container() -> DataContainerMixin: - dc = DataContainerMixin() - dc.set_data("TEXT") - dc.set_data(200) - dc.set_data({"a": "b"}) - return dc - - -async def _async_callback( - text: str = data(str), number: int = data(int), mapping: dict = data(dict) -): ... - - -def _sync_callback( - text: str = data(str), number: int = data(int), mapping: dict = data(dict) -): ... - - -def _invalid_callback(number: float = data(float)): ... - - -@pytest.mark.asyncio -async def test_data_container_invoke_async_callback(data_container: DataContainerMixin): - await data_container._invoke_callback(_async_callback) - - -@pytest.mark.asyncio -async def test_data_container_invoke_sync_callback(data_container: DataContainerMixin): - await data_container._invoke_callback(_sync_callback) - - -def test_data_container_raises_data_already_exists(data_container: DataContainerMixin): - with pytest.raises( - TopGGException, - match=" already exists. If you wish to override it, " - "pass True into the override parameter.", - ): - data_container.set_data("TEST") - - -@pytest.mark.asyncio -async def test_data_container_raises_key_error(data_container: DataContainerMixin): - with pytest.raises(KeyError): - await data_container._invoke_callback(_invalid_callback) - - -def test_data_container_get_data(data_container: DataContainerMixin): - assert data_container.get_data(str) == "TEXT" - assert data_container.get_data(float) is None - assert isinstance(data_container.get_data(set, set()), set) diff --git a/tests/test_ratelimiter.py b/tests/test_ratelimiter.py index 53692fe4..9153b3a1 100644 --- a/tests/test_ratelimiter.py +++ b/tests/test_ratelimiter.py @@ -1,26 +1,26 @@ import pytest -from topgg.ratelimiter import Ratelimiter +from topgg.ratelimiter import AsyncRateLimiter n = period = 10 @pytest.fixture -def limiter() -> Ratelimiter: - return Ratelimiter(max_calls=n, period=period) +def limiter() -> AsyncRateLimiter: + return AsyncRateLimiter(max_calls=n, period=period) @pytest.mark.asyncio -async def test_AsyncRateLimiter_calls(limiter: Ratelimiter) -> None: +async def test_AsyncRateLimiter_calls(limiter: AsyncRateLimiter) -> None: for _ in range(n): async with limiter: pass - assert len(limiter._Ratelimiter__calls) == limiter._Ratelimiter__max_calls == n + assert len(limiter.calls) == limiter.max_calls == n @pytest.mark.asyncio -async def test_AsyncRateLimiter_timespan_property(limiter: Ratelimiter) -> None: +async def test_AsyncRateLimiter_timespan_property(limiter: AsyncRateLimiter) -> None: for _ in range(n): async with limiter: pass diff --git a/tests/test_type.py b/tests/test_type.py index b30bfcf1..4a7acc23 100644 --- a/tests/test_type.py +++ b/tests/test_type.py @@ -3,28 +3,35 @@ from topgg import types d: dict = { - "invite": "https://top.gg/discord", - "support": "https://discord.gg/dbl", - "github": "https://github.com/top-gg", - "longdesc": "A bot to grant API access to our Library Developers on the Top.gg site without them needing to submit a bot to pass verification just to be able to access the API.\n\nThis is not a real bot, so if you happen to find this page, do not try to invite it. It will not work.\n\nAccess to this bot's team can be requested by contacting a Community Manager in [our Discord server](https://top.gg/discord).", - "shortdesc": "API access for Top.gg Library Developers", - "prefix": "/", - "lib": "", - "clientid": "1026525568344264724", - "avatar": "https://cdn.discordapp.com/avatars/1026525568344264724/cd70e62e41f691f1c05c8455d8c31e23.png", - "id": "1026525568344264724", - "username": "Top.gg Lib Dev API Access", - "date": "2022-10-03T16:08:55.292Z", + "defAvatar": "6debd47ed13483642cf09e832ed0bc1b", + "invite": "", + "website": "https://top.gg", + "support": "KYZsaFb", + "github": "https://github.com/top-gg/Luca", + "longdesc": "Luca only works in the **Discord Bot List** server. \nPrepend commands with the prefix `-` or " + "`@Luca#1375`. \n**Please refrain from using these commands in non testing channels.**\n- `botinfo " + "@bot` Shows bot info, title redirects to site listing.\n- `bots @user`* Shows all bots of that user, " + "includes bots in the queue.\n- `owner / -owners @bot`* Shows all owners of that bot.\n- `prefix " + "@bot`* Shows the prefix of that bot.\n* Mobile friendly version exists. Just add `noembed` to the " + "end of the command.\n", + "shortdesc": "Luca is a bot for managing and informing members of the server", + "prefix": "- or @Luca#1375", + "lib": None, + "clientid": "264811613708746752", + "avatar": "7edcc4c6fbb0b23762455ca139f0e1c9", + "id": "264811613708746752", + "discriminator": "1375", + "username": "Luca", + "date": "2017-04-26T18:08:17.125Z", "server_count": 2, - "shard_count": 0, - "guilds": [], + "guilds": ["417723229721853963", "264445053596991498"], "shards": [], - "monthlyPoints": 2, - "points": 28, + "monthlyPoints": 19, + "points": 397, "certifiedBot": False, - "owners": ["121919449996460033"], - "tags": ["api", "library", "topgg"], - "reviews": {"averageScore": 5, "count": 2}, + "owners": ["129908908096487424"], + "tags": ["Moderation", "Role Management", "Logging"], + "donatebotguildid": "", } query_dict = {"qwe": "1", "rty": "2", "uio": "3"} @@ -40,7 +47,6 @@ "user": "3", "type": "test", "query": "?" + "&".join(f"{k}={v}" for k, v in query_dict.items()), - "isWeekend": False, } server_vote_dict = { @@ -50,84 +56,147 @@ "query": "?" + "&".join(f"{k}={v}" for k, v in query_dict.items()), } -bot_stats_dict = {"server_count": 2, "shards": [], "shard_count": 0} +user_data_dict = { + "discriminator": "0001", + "avatar": "a_1241439d430def25c100dd28add2d42f", + "id": "140862798832861184", + "username": "Xetera", + "defAvatar": "322c936a8c8be1b803cd94861bdfa868", + "admin": True, + "webMod": True, + "mod": True, + "certifiedDev": False, + "supporter": False, + "social": {}, +} + +bot_stats_dict = {"shards": [1, 5, 8]} + + +@pytest.fixture +def data_dict() -> types.DataDict: + return types.DataDict(**d) @pytest.fixture def bot_data() -> types.BotData: - return types.BotData(d) + return types.BotData(**d) + + +@pytest.fixture +def user_data() -> types.UserData: + return types.UserData(**user_data_dict) @pytest.fixture def widget_options() -> types.WidgetOptions: - return types.WidgetOptions( - id=int(d["id"]), - project_type=types.WidgetProjectType.DISCORD_BOT, - type=types.WidgetType.LARGE, - ) + return types.WidgetOptions(id=int(d["id"])) @pytest.fixture def vote_data() -> types.VoteDataDict: - return types.VoteDataDict(vote_data_dict) + return types.VoteDataDict(**vote_data_dict) @pytest.fixture def bot_vote_data() -> types.BotVoteData: - return types.BotVoteData(bot_vote_dict) + return types.BotVoteData(**bot_vote_dict) @pytest.fixture -def server_vote_data() -> types.GuildVoteData: - return types.GuildVoteData(server_vote_dict) +def server_vote_data() -> types.ServerVoteData: + return types.ServerVoteData(**server_vote_dict) @pytest.fixture def bot_stats_data() -> types.BotStatsData: - return types.BotStatsData(bot_stats_dict) + return types.BotStatsData(**bot_stats_dict) + + +def test_data_dict_fields(data_dict: types.DataDict) -> None: + for attr in data_dict: + if "id" in attr.lower(): + assert isinstance(data_dict[attr], int) or data_dict[attr] is None + assert data_dict.get(attr) == data_dict[attr] == getattr(data_dict, attr) def test_bot_data_fields(bot_data: types.BotData) -> None: bot_data.github = "I'm a GitHub link!" bot_data.support = "Support has arrived!" - for attr in bot_data.__slots__: + for attr in bot_data: if "id" in attr.lower(): - value = getattr(bot_data, attr) - - assert isinstance(value, int) or value is None + assert isinstance(bot_data[attr], int) or bot_data[attr] is None elif attr in ("owners", "guilds"): - for item in getattr(bot_data, attr): + for item in bot_data[attr]: assert isinstance(item, int) + assert bot_data.get(attr) == bot_data[attr] == getattr(bot_data, attr) + + +def test_widget_options_fields(widget_options: types.WidgetOptions) -> None: + assert widget_options["colors"] == widget_options["colours"] + + widget_options.colours = {"background": 0} + widget_options["colours"]["text"] = 255 + assert widget_options.colours == widget_options["colors"] + + for attr in widget_options: + if "id" in attr.lower(): + assert isinstance(widget_options[attr], int) or widget_options[attr] is None + assert ( + widget_options.get(attr) + == widget_options[attr] + == widget_options[attr] + == getattr(widget_options, attr) + ) def test_vote_data_fields(vote_data: types.VoteDataDict) -> None: assert isinstance(vote_data.query, dict) vote_data.type = "upvote" + for attr in vote_data: + assert getattr(vote_data, attr) == vote_data.get(attr) == vote_data[attr] + def test_bot_vote_data_fields(bot_vote_data: types.BotVoteData) -> None: assert isinstance(bot_vote_data.query, dict) bot_vote_data.type = "upvote" - assert isinstance(bot_vote_data.bot, int) + assert isinstance(bot_vote_data["bot"], int) + for attr in bot_vote_data: + assert ( + getattr(bot_vote_data, attr) + == bot_vote_data.get(attr) + == bot_vote_data[attr] + ) def test_server_vote_data_fields(server_vote_data: types.BotVoteData) -> None: assert isinstance(server_vote_data.query, dict) server_vote_data.type = "upvote" - assert isinstance(server_vote_data.guild, int) + assert isinstance(server_vote_data["guild"], int) + for attr in server_vote_data: + assert ( + getattr(server_vote_data, attr) + == server_vote_data.get(attr) + == server_vote_data[attr] + ) def test_bot_stats_data_attrs(bot_stats_data: types.BotStatsData) -> None: for count in ("server_count", "shard_count"): - value = getattr(bot_stats_data, count) - - assert isinstance(value, int) or value is None - + assert isinstance(bot_stats_data[count], int) or bot_stats_data[count] is None assert isinstance(bot_stats_data.shards, list) - if bot_stats_data.shards: for shard in bot_stats_data.shards: assert isinstance(shard, int) + + +def test_user_data_attrs(user_data: types.UserData) -> None: + assert isinstance(user_data.social, types.SocialData) + for attr in user_data: + if "id" in attr.lower(): + assert isinstance(user_data[attr], int) or user_data[attr] is None + assert user_data[attr] == getattr(user_data, attr) == user_data.get(attr) diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 00000000..e4e61b47 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,7 @@ +import topgg + + +def test_topgg_validates_version() -> None: + assert topgg.__version__.split(".") == [ + str(getattr(topgg.version_info, i)) for i in ("major", "minor", "micro") + ] diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 863fd627..e4fb3930 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -1,47 +1,60 @@ -import typing as t +from typing import TYPE_CHECKING, Dict import aiohttp -import mock import pytest +from discord import Client -from topgg import WebhookManager, WebhookType -from topgg.errors import TopGGException +from topgg import WebhookManager + +if TYPE_CHECKING: + from topgg.types import BotVoteData, ServerVoteData auth = "youshallnotpass" @pytest.fixture def webhook_manager() -> WebhookManager: - return ( - WebhookManager() - .endpoint() - .type(WebhookType.BOT) - .auth(auth) - .route("/dbl") - .callback(print) - .add_to_manager() - .endpoint() - .type(WebhookType.GUILD) - .auth(auth) - .route("/dsl") - .callback(print) - .add_to_manager() - ) + return WebhookManager(Client()).dbl_webhook("/dbl", auth).dsl_webhook("/dsl", auth) def test_WebhookManager_routes(webhook_manager: WebhookManager) -> None: - assert len(webhook_manager.app.router.routes()) == 2 + assert len(webhook_manager._webhooks) == 2 + + +@pytest.mark.asyncio +async def test_WebhookManager_run_method(webhook_manager: WebhookManager) -> None: + task = webhook_manager.run(5000) + + try: + if not task.done(): + assert await task is None + + assert hasattr(webhook_manager, "_webserver") + finally: + await webhook_manager.close() @pytest.mark.asyncio @pytest.mark.parametrize( "headers, result, state", - [({"authorization": auth}, 204, True), ({}, 401, False)], + [({"authorization": auth}, 200, True), ({}, 401, False)], ) async def test_WebhookManager_validates_auth( - webhook_manager: WebhookManager, headers: t.Dict[str, str], result: int, state: bool + webhook_manager: WebhookManager, headers: Dict[str, str], result: int, state: bool ) -> None: - await webhook_manager.start(5000) + await webhook_manager.run(5000) + + dbl_state = dsl_state = False + + @webhook_manager.bot.event + async def on_dbl_vote(data: "BotVoteData") -> None: + nonlocal dbl_state + dbl_state = True + + @webhook_manager.bot.event + async def on_dsl_vote(data: "ServerVoteData") -> None: + nonlocal dsl_state + dsl_state = True try: for path in ("dbl", "dsl"): @@ -49,32 +62,7 @@ async def test_WebhookManager_validates_auth( "POST", f"http://localhost:5000/{path}", headers=headers, json={} ) as r: assert r.status == result + + assert locals()[f"{path}_state"] is state finally: await webhook_manager.close() - assert not webhook_manager.is_running - - -def test_WebhookEndpoint_callback_unset(webhook_manager: WebhookManager): - with pytest.raises( - TopGGException, - match="endpoint missing callback.", - ): - webhook_manager.endpoint().add_to_manager() - - -def test_WebhookEndpoint_route_unset(webhook_manager: WebhookManager): - with pytest.raises( - TopGGException, - match="endpoint missing type.", - ): - webhook_manager.endpoint().callback(mock.Mock()).add_to_manager() - - -def test_WebhookEndpoint_type_unset(webhook_manager: WebhookManager): - with pytest.raises( - TopGGException, - match="endpoint missing route.", - ): - webhook_manager.endpoint().callback(mock.Mock()).type( - WebhookType.BOT - ).add_to_manager() From eaab5f1f0c15196fe4574e416658ce7b661f5d01 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 21 Oct 2025 16:16:38 +0700 Subject: [PATCH 17/19] revert: revert github workflow modifications --- .github/workflows/python-package.yml | 41 ---------------------------- .github/workflows/python-publish.yml | 31 --------------------- .github/workflows/release.yml | 21 ++++++++++++++ .github/workflows/test.yml | 22 +++++++++++++++ 4 files changed, 43 insertions(+), 72 deletions(-) delete mode 100644 .github/workflows/python-package.yml delete mode 100644 .github/workflows/python-publish.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml deleted file mode 100644 index 60b37a8f..00000000 --- a/.github/workflows/python-package.yml +++ /dev/null @@ -1,41 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - -name: Test Python package - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - build: - - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [ 3.6, 3.7, 3.8, 3.9 ] - - steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install testing dependencies - uses: py-actions/py-dependency-install@v2 - with: - path: "requirements-dev.txt" - - name: Install itself - run: | - python setup.py install - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics - - name: Test with pytest - run: | - pytest diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml deleted file mode 100644 index 1eba4d89..00000000 --- a/.github/workflows/python-publish.yml +++ /dev/null @@ -1,31 +0,0 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - -name: Upload Python Package - -on: - release: - types: [ created ] - -jobs: - deploy: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.7' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine -r requirements.txt - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python setup.py sdist bdist_wheel - twine upload dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..6f637a26 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,21 @@ +name: Publish +on: + release: + types: [created] +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: 3.13 + - name: Install dependencies + run: python3 -m pip install build twine + - name: Build and publish + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: | + python3 -m build + python3 -m twine upload dist/* \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..c077d633 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Test +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ 3.9, '3.10', 3.11, 3.12, 3.13 ] + steps: + - uses: actions/checkout@v5 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: python -m pip install .[dev] + - name: Test with pytest + run: pytest From 212693ab0eb982ba72b61bed7078c4381ff1f4d7 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 21 Oct 2025 16:24:39 +0700 Subject: [PATCH 18/19] revert: revert changes done to tests, examples, and MANIFEST.in --- MANIFEST.in | 21 +- examples/discordpy_example/__main__.py | 45 ++-- .../discordpy_example/callbacks/autopost.py | 45 ++-- .../discordpy_example/callbacks/webhook.py | 44 ++-- examples/hikari_example/__main__.py | 45 ++-- examples/hikari_example/callbacks/autopost.py | 46 ++-- examples/hikari_example/callbacks/webhook.py | 45 ++-- examples/hikari_example/events/autopost.py | 45 ++-- examples/hikari_example/events/webhook.py | 45 ++-- tests/test_autopost.py | 95 +++++++++ tests/test_client.py | 197 +++--------------- tests/test_data_container.py | 60 ++++++ tests/test_ratelimiter.py | 12 +- tests/test_type.py | 4 +- tests/test_version.py | 7 - tests/test_webhook.py | 86 ++++---- 16 files changed, 429 insertions(+), 413 deletions(-) create mode 100644 tests/test_autopost.py create mode 100644 tests/test_data_container.py delete mode 100644 tests/test_version.py diff --git a/MANIFEST.in b/MANIFEST.in index dc068afa..a0e44e5b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,18 @@ -include LICENSE -include requirements.txt -include README.rst +prune .github +prune .pytest_cache +prune .ruff_cache +prune build +prune docs +prune examples +prune scripts +prune tests + +exclude .coverage +exclude .gitignore +exclude .readthedocs.yml +exclude ISSUE_TEMPLATE.md +exclude mypy.ini +exclude PULL_REQUEST_TEMPLATE.md +exclude pytest.ini +exclude ruff.toml +exclude LICENSE \ No newline at end of file diff --git a/examples/discordpy_example/__main__.py b/examples/discordpy_example/__main__.py index 0550068e..f1c1f6dd 100644 --- a/examples/discordpy_example/__main__.py +++ b/examples/discordpy_example/__main__.py @@ -1,27 +1,24 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021 Norizon - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - +# The MIT License (MIT) + +# Copyright (c) 2021 Norizon + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. import discord import topgg diff --git a/examples/discordpy_example/callbacks/autopost.py b/examples/discordpy_example/callbacks/autopost.py index 76da7d8b..e6592a6d 100644 --- a/examples/discordpy_example/callbacks/autopost.py +++ b/examples/discordpy_example/callbacks/autopost.py @@ -1,27 +1,24 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021 Norizon - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - +# The MIT License (MIT) + +# Copyright (c) 2021 Norizon + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. import sys import discord diff --git a/examples/discordpy_example/callbacks/webhook.py b/examples/discordpy_example/callbacks/webhook.py index 299327f4..358753c1 100644 --- a/examples/discordpy_example/callbacks/webhook.py +++ b/examples/discordpy_example/callbacks/webhook.py @@ -1,26 +1,24 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021 Norizon - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" +# The MIT License (MIT) + +# Copyright (c) 2021 Norizon + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. # import discord diff --git a/examples/hikari_example/__main__.py b/examples/hikari_example/__main__.py index b1111190..0bef502f 100644 --- a/examples/hikari_example/__main__.py +++ b/examples/hikari_example/__main__.py @@ -1,27 +1,24 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021 Norizon - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - +# The MIT License (MIT) + +# Copyright (c) 2021 Norizon + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. import hikari import topgg diff --git a/examples/hikari_example/callbacks/autopost.py b/examples/hikari_example/callbacks/autopost.py index dd737fd3..3ac467b3 100644 --- a/examples/hikari_example/callbacks/autopost.py +++ b/examples/hikari_example/callbacks/autopost.py @@ -1,27 +1,24 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021 Norizon - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - +# The MIT License (MIT) + +# Copyright (c) 2021 Norizon + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. import logging import hikari @@ -32,7 +29,6 @@ _LOGGER = logging.getLogger("callbacks.autopost") - # these functions can be async too! def on_autopost_success( # uncomment this if you want to get access to app diff --git a/examples/hikari_example/callbacks/webhook.py b/examples/hikari_example/callbacks/webhook.py index 5c5d722d..50c53a73 100644 --- a/examples/hikari_example/callbacks/webhook.py +++ b/examples/hikari_example/callbacks/webhook.py @@ -1,26 +1,24 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021 Norizon - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" +# The MIT License (MIT) + +# Copyright (c) 2021 Norizon + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. import logging @@ -33,7 +31,6 @@ _LOGGER = logging.getLogger("callbacks.webhook") - # this can be async too! @topgg.endpoint("/dblwebhook", topgg.WebhookType.BOT, "youshallnotpass") async def endpoint( diff --git a/examples/hikari_example/events/autopost.py b/examples/hikari_example/events/autopost.py index 02e9967d..ddc7aa22 100644 --- a/examples/hikari_example/events/autopost.py +++ b/examples/hikari_example/events/autopost.py @@ -1,27 +1,24 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021 Norizon - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - +# The MIT License (MIT) + +# Copyright (c) 2021 Norizon + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. import attr import hikari diff --git a/examples/hikari_example/events/webhook.py b/examples/hikari_example/events/webhook.py index c3dfc729..b9b6d21f 100644 --- a/examples/hikari_example/events/webhook.py +++ b/examples/hikari_example/events/webhook.py @@ -1,27 +1,24 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021 Norizon - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - +# The MIT License (MIT) + +# Copyright (c) 2021 Norizon + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. import attr import hikari diff --git a/tests/test_autopost.py b/tests/test_autopost.py new file mode 100644 index 00000000..726355bd --- /dev/null +++ b/tests/test_autopost.py @@ -0,0 +1,95 @@ +import datetime + +import mock +import pytest +from aiohttp import ClientSession +from pytest_mock import MockerFixture + +from topgg import DBLClient +from topgg.autopost import AutoPoster +from topgg.errors import HTTPException, TopGGException + + +MOCK_TOKEN = ".eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=." + + +@pytest.fixture +def session() -> ClientSession: + return mock.Mock(ClientSession) + + +@pytest.fixture +def autopost(session: ClientSession) -> AutoPoster: + return AutoPoster(DBLClient(MOCK_TOKEN, session=session)) + + +@pytest.mark.asyncio +async def test_AutoPoster_breaks_autopost_loop_on_401( + mocker: MockerFixture, session: ClientSession +) -> None: + response = mock.Mock("reason, status") + response.reason = "Unauthorized" + response.status = 401 + + mocker.patch( + "topgg.DBLClient.post_guild_count", side_effect=HTTPException(response, {}) + ) + + callback = mock.Mock() + autopost = DBLClient(MOCK_TOKEN, session=session).autopost().stats(callback) + assert isinstance(autopost, AutoPoster) + assert not isinstance(autopost.stats()(callback), AutoPoster) + + with pytest.raises(HTTPException): + await autopost.start() + + callback.assert_called_once() + assert not autopost.is_running + + +@pytest.mark.asyncio +async def test_AutoPoster_raises_missing_stats(autopost: AutoPoster) -> None: + with pytest.raises( + TopGGException, match="you must provide a callback that returns the stats." + ): + await autopost.start() + + +@pytest.mark.asyncio +async def test_AutoPoster_raises_already_running(autopost: AutoPoster) -> None: + autopost.stats(mock.Mock()).start() + with pytest.raises(TopGGException, match="the autopost is already running."): + await autopost.start() + + +@pytest.mark.asyncio +async def test_AutoPoster_interval_too_short(autopost: AutoPoster) -> None: + with pytest.raises(ValueError, match="interval must be greated than 900 seconds."): + autopost.set_interval(50) + + +@pytest.mark.asyncio +async def test_AutoPoster_error_callback( + mocker: MockerFixture, autopost: AutoPoster +) -> None: + error_callback = mock.Mock() + response = mock.Mock("reason, status") + response.reason = "Internal Server Error" + response.status = 500 + side_effect = HTTPException(response, {}) + + mocker.patch("topgg.DBLClient.post_guild_count", side_effect=side_effect) + task = autopost.on_error(error_callback).stats(mock.Mock()).start() + autopost.stop() + await task + error_callback.assert_called_once_with(side_effect) + + +def test_AutoPoster_interval(autopost: AutoPoster): + assert autopost.interval == 900 + autopost.set_interval(datetime.timedelta(hours=1)) + assert autopost.interval == 3600 + autopost.interval = datetime.timedelta(hours=2) + assert autopost.interval == 7200 + autopost.interval = 3600 + assert autopost.interval == 3600 diff --git a/tests/test_client.py b/tests/test_client.py index b29f8ded..f0a9c456 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,189 +1,54 @@ -import asyncio -from typing import Optional - import mock import pytest -from aiohttp import ClientSession -from discord import Client -from discord.ext.commands import Bot -from pytest import CaptureFixture -from pytest_mock import MockerFixture - -from topgg import DBLClient -from topgg.errors import ClientException, Unauthorized, UnauthorizedDetected - - -@pytest.fixture -def bot() -> Client: - bot = mock.Mock(Client) - bot.loop = asyncio.get_event_loop() - bot.guilds = [] - bot.is_closed.return_value = False - return bot +import topgg -@pytest.fixture -def session() -> ClientSession: - return mock.Mock(ClientSession) +MOCK_TOKEN = ".eyJfdCI6IiIsImlkIjoiMzY0ODA2MDI5ODc2NTU1Nzc2In0=." -@pytest.fixture -def exc() -> Exception: - return Exception("Test Exception") - -@pytest.mark.parametrize( - "autopost, post_shard_count, autopost_interval", - [ - (True, True, 900), - (True, False, 900), - (True, True, None), - (True, False, None), - (False, False, None), - (False, False, 0), - ], -) @pytest.mark.asyncio -async def test_DBLClient_validates_constructor_and_passes_for_valid_values( - bot: Client, - mocker: MockerFixture, - autopost: bool, - post_shard_count: bool, - autopost_interval: Optional[int], - session: ClientSession, -) -> None: - mocker.patch("topgg.DBLClient._auto_post", new_callable=mock.AsyncMock) # type: ignore - DBLClient( - bot, - "", - session=session, - autopost=autopost, - post_shard_count=post_shard_count, - autopost_interval=autopost_interval, - ) - - -@pytest.mark.parametrize( - "autopost, post_shard_count, autopost_interval", - [ - (True, True, 0), - (True, False, 500), - (False, True, 0), - (False, True, 900), - (False, True, None), - (False, False, 1800), - ], -) -def test_DBLClient_validates_constructor_and_fails_for_invalid_values( - bot: Client, - mocker: MockerFixture, - autopost: bool, - post_shard_count: bool, - autopost_interval: Optional[int], - session: ClientSession, -) -> None: - with pytest.raises(ClientException): - DBLClient( - bot, - "", - session=session, - autopost=autopost, - post_shard_count=post_shard_count, - autopost_interval=autopost_interval, - ) +async def test_DBLClient_post_guild_count_with_no_args(): + client = topgg.DBLClient(MOCK_TOKEN) + with pytest.raises(TypeError, match="stats or guild_count must be provided."): + await client.post_guild_count() @pytest.mark.asyncio -async def test_DBLClient_breaks_autopost_loop_on_401( - bot: Client, mocker: MockerFixture, session: ClientSession -) -> None: - response = mock.Mock("reason, status") - response.reason = "Unauthorized" - response.status = 401 - - mocker.patch( - "topgg.DBLClient.post_guild_count", side_effect=Unauthorized(response, {}) - ) - mocker.patch( - "topgg.DBLClient._ensure_bot_user", - new_callable=mock.AsyncMock, # type: ignore - ) - - obj = DBLClient(bot, "", False, session=session) - - with pytest.raises(Unauthorized): - await obj._auto_post() +async def test_DBLClient_get_weekend_status(monkeypatch): + client = topgg.DBLClient(MOCK_TOKEN) + monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock()) + await client.get_weekend_status() + client._DBLClient__request.assert_called_once() @pytest.mark.asyncio -@pytest.mark.parametrize( - "token", - [None, ""], - # treat None as str to suppress mypy -) -async def test_HTTPClient_fails_for_no_token( - bot: Client, token: str, session: ClientSession -) -> None: - with pytest.raises(UnauthorizedDetected): - await DBLClient(bot=bot, token=token, session=session).post_guild_count() +async def test_DBLClient_post_guild_count(monkeypatch): + client = topgg.DBLClient(MOCK_TOKEN) + monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock()) + await client.post_guild_count(guild_count=123) + client._DBLClient__request.assert_called_once() @pytest.mark.asyncio -async def test_Client_with_default_autopost_error_handler( - mocker: MockerFixture, - capsys: CaptureFixture[str], - session: ClientSession, - exc: Exception, -) -> None: - client = Client() - mocker.patch("topgg.DBLClient._auto_post", new_callable=mock.AsyncMock) # type: ignore - dbl = DBLClient(client, "", True, session=session) - assert client.on_autopost_error == dbl.on_autopost_error - await client.on_autopost_error(exc) - assert "Ignoring exception in auto post loop" in capsys.readouterr().err +async def test_DBLClient_get_guild_count(monkeypatch): + client = topgg.DBLClient(MOCK_TOKEN) + monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock(return_value={})) + await client.get_guild_count() + client._DBLClient__request.assert_called_once() @pytest.mark.asyncio -async def test_Client_with_custom_autopost_error_handler( - mocker: MockerFixture, session: ClientSession, exc: Exception -) -> None: - client = Client() - state = False - - @client.event - async def on_autopost_error(exc: Exception) -> None: - nonlocal state - state = True - - mocker.patch("topgg.DBLClient._auto_post", new_callable=mock.AsyncMock) # type: ignore - DBLClient(client, "", True, session=session) - await client.on_autopost_error(exc) - assert state +async def test_DBLClient_get_bot_votes(monkeypatch): + client = topgg.DBLClient(MOCK_TOKEN) + monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock(return_value=[])) + await client.get_bot_votes() + client._DBLClient__request.assert_called_once() @pytest.mark.asyncio -async def test_Bot_with_autopost_error_listener( - mocker: MockerFixture, - capsys: CaptureFixture[str], - session: ClientSession, - exc: Exception, -) -> None: - bot = Bot("") - state = False - - @bot.listen() - async def on_autopost_error(exc: Exception) -> None: - nonlocal state - state = True - - mocker.patch("topgg.DBLClient._auto_post", new_callable=mock.AsyncMock) # type: ignore - DBLClient(bot, "", True, session=session) - - # await to make sure all the listeners were run before asserting - # as .dispatch schedules the events and will continue - # to the assert line without finishing the event callbacks - await bot.on_autopost_error(exc) - await on_autopost_error(exc) - - assert "Ignoring exception in auto post loop" not in capsys.readouterr().err - assert state +async def test_DBLClient_get_user_vote(monkeypatch): + client = topgg.DBLClient(MOCK_TOKEN) + monkeypatch.setattr("topgg.DBLClient._DBLClient__request", mock.AsyncMock(return_value={"voted": 1})) + await client.get_user_vote(1234) + client._DBLClient__request.assert_called_once() diff --git a/tests/test_data_container.py b/tests/test_data_container.py new file mode 100644 index 00000000..978574fb --- /dev/null +++ b/tests/test_data_container.py @@ -0,0 +1,60 @@ +import pytest + +from topgg.data import DataContainerMixin, data +from topgg.errors import TopGGException + + +@pytest.fixture +def data_container() -> DataContainerMixin: + dc = DataContainerMixin() + dc.set_data("TEXT") + dc.set_data(200) + dc.set_data({"a": "b"}) + return dc + + +async def _async_callback( + text: str = data(str), number: int = data(int), mapping: dict = data(dict) +): + ... + + +def _sync_callback( + text: str = data(str), number: int = data(int), mapping: dict = data(dict) +): + ... + + +def _invalid_callback(number: float = data(float)): + ... + + +@pytest.mark.asyncio +async def test_data_container_invoke_async_callback(data_container: DataContainerMixin): + await data_container._invoke_callback(_async_callback) + + +@pytest.mark.asyncio +async def test_data_container_invoke_sync_callback(data_container: DataContainerMixin): + await data_container._invoke_callback(_sync_callback) + + +def test_data_container_raises_data_already_exists(data_container: DataContainerMixin): + with pytest.raises( + TopGGException, + match=" already exists. If you wish to override it, " + "pass True into the override parameter.", + ): + data_container.set_data("TEST") + + +@pytest.mark.asyncio +async def test_data_container_raises_key_error(data_container: DataContainerMixin): + with pytest.raises(KeyError): + await data_container._invoke_callback(_invalid_callback) + + +def test_data_container_get_data(data_container: DataContainerMixin): + assert data_container.get_data(str) == "TEXT" + assert data_container.get_data(float) is None + assert isinstance(data_container.get_data(set, set()), set) diff --git a/tests/test_ratelimiter.py b/tests/test_ratelimiter.py index 9153b3a1..53692fe4 100644 --- a/tests/test_ratelimiter.py +++ b/tests/test_ratelimiter.py @@ -1,26 +1,26 @@ import pytest -from topgg.ratelimiter import AsyncRateLimiter +from topgg.ratelimiter import Ratelimiter n = period = 10 @pytest.fixture -def limiter() -> AsyncRateLimiter: - return AsyncRateLimiter(max_calls=n, period=period) +def limiter() -> Ratelimiter: + return Ratelimiter(max_calls=n, period=period) @pytest.mark.asyncio -async def test_AsyncRateLimiter_calls(limiter: AsyncRateLimiter) -> None: +async def test_AsyncRateLimiter_calls(limiter: Ratelimiter) -> None: for _ in range(n): async with limiter: pass - assert len(limiter.calls) == limiter.max_calls == n + assert len(limiter._Ratelimiter__calls) == limiter._Ratelimiter__max_calls == n @pytest.mark.asyncio -async def test_AsyncRateLimiter_timespan_property(limiter: AsyncRateLimiter) -> None: +async def test_AsyncRateLimiter_timespan_property(limiter: Ratelimiter) -> None: for _ in range(n): async with limiter: pass diff --git a/tests/test_type.py b/tests/test_type.py index 4a7acc23..caec363c 100644 --- a/tests/test_type.py +++ b/tests/test_type.py @@ -104,8 +104,8 @@ def bot_vote_data() -> types.BotVoteData: @pytest.fixture -def server_vote_data() -> types.ServerVoteData: - return types.ServerVoteData(**server_vote_dict) +def server_vote_data() -> types.GuildVoteData: + return types.GuildVoteData(**server_vote_dict) @pytest.fixture diff --git a/tests/test_version.py b/tests/test_version.py deleted file mode 100644 index e4e61b47..00000000 --- a/tests/test_version.py +++ /dev/null @@ -1,7 +0,0 @@ -import topgg - - -def test_topgg_validates_version() -> None: - assert topgg.__version__.split(".") == [ - str(getattr(topgg.version_info, i)) for i in ("major", "minor", "micro") - ] diff --git a/tests/test_webhook.py b/tests/test_webhook.py index e4fb3930..93fbc1d4 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -1,37 +1,36 @@ -from typing import TYPE_CHECKING, Dict +import typing as t import aiohttp +import mock import pytest -from discord import Client -from topgg import WebhookManager - -if TYPE_CHECKING: - from topgg.types import BotVoteData, ServerVoteData +from topgg import WebhookManager, WebhookType +from topgg.errors import TopGGException auth = "youshallnotpass" @pytest.fixture def webhook_manager() -> WebhookManager: - return WebhookManager(Client()).dbl_webhook("/dbl", auth).dsl_webhook("/dsl", auth) + return ( + WebhookManager() + .endpoint() + .type(WebhookType.BOT) + .auth(auth) + .route("/dbl") + .callback(print) + .add_to_manager() + .endpoint() + .type(WebhookType.GUILD) + .auth(auth) + .route("/dsl") + .callback(print) + .add_to_manager() + ) def test_WebhookManager_routes(webhook_manager: WebhookManager) -> None: - assert len(webhook_manager._webhooks) == 2 - - -@pytest.mark.asyncio -async def test_WebhookManager_run_method(webhook_manager: WebhookManager) -> None: - task = webhook_manager.run(5000) - - try: - if not task.done(): - assert await task is None - - assert hasattr(webhook_manager, "_webserver") - finally: - await webhook_manager.close() + assert len(webhook_manager.app.router.routes()) == 2 @pytest.mark.asyncio @@ -40,21 +39,9 @@ async def test_WebhookManager_run_method(webhook_manager: WebhookManager) -> Non [({"authorization": auth}, 200, True), ({}, 401, False)], ) async def test_WebhookManager_validates_auth( - webhook_manager: WebhookManager, headers: Dict[str, str], result: int, state: bool + webhook_manager: WebhookManager, headers: t.Dict[str, str], result: int, state: bool ) -> None: - await webhook_manager.run(5000) - - dbl_state = dsl_state = False - - @webhook_manager.bot.event - async def on_dbl_vote(data: "BotVoteData") -> None: - nonlocal dbl_state - dbl_state = True - - @webhook_manager.bot.event - async def on_dsl_vote(data: "ServerVoteData") -> None: - nonlocal dsl_state - dsl_state = True + await webhook_manager.start(5000) try: for path in ("dbl", "dsl"): @@ -62,7 +49,32 @@ async def on_dsl_vote(data: "ServerVoteData") -> None: "POST", f"http://localhost:5000/{path}", headers=headers, json={} ) as r: assert r.status == result - - assert locals()[f"{path}_state"] is state finally: await webhook_manager.close() + assert not webhook_manager.is_running + + +def test_WebhookEndpoint_callback_unset(webhook_manager: WebhookManager): + with pytest.raises( + TopGGException, + match="endpoint missing callback.", + ): + webhook_manager.endpoint().add_to_manager() + + +def test_WebhookEndpoint_route_unset(webhook_manager: WebhookManager): + with pytest.raises( + TopGGException, + match="endpoint missing type.", + ): + webhook_manager.endpoint().callback(mock.Mock()).add_to_manager() + + +def test_WebhookEndpoint_type_unset(webhook_manager: WebhookManager): + with pytest.raises( + TopGGException, + match="endpoint missing route.", + ): + webhook_manager.endpoint().callback(mock.Mock()).type( + WebhookType.BOT + ).add_to_manager() From 8293c0ef4bda465821235638c1e2c8e49a8bc30a Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 21 Oct 2025 16:54:27 +0700 Subject: [PATCH 19/19] revert: revert documentation modifications, that goes in another pull request --- docs/api/types.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/api/types.rst b/docs/api/types.rst index 44c11251..a6a70f84 100644 --- a/docs/api/types.rst +++ b/docs/api/types.rst @@ -5,6 +5,10 @@ Models API Reference .. automodule:: topgg.types :members: +.. autoclass:: topgg.types.DataDict + :members: + :inherited-members: + .. autoclass:: topgg.types.BotData :members: :show-inheritance: