From 6db23f6490ce1c7cbcfdac33cd4270e01a24947d Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 26 Mar 2024 18:16:42 +0700 Subject: [PATCH 001/126] refactor: remove unused import The following pull request ~~adds malware~~ removes unused `StatsWrapper` import in the `tests/test_autopost.py` file. --- tests/test_autopost.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_autopost.py b/tests/test_autopost.py index a4f8ee7a..7cf39c14 100644 --- a/tests/test_autopost.py +++ b/tests/test_autopost.py @@ -5,7 +5,7 @@ from aiohttp import ClientSession from pytest_mock import MockerFixture -from topgg import DBLClient, StatsWrapper +from topgg import DBLClient from topgg.autopost import AutoPoster from topgg.errors import ServerError, TopGGException, Unauthorized From a9f6ee7d6348d6e2099e58fac1b66c7435f3a88e Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 26 Mar 2024 18:30:08 +0700 Subject: [PATCH 002/126] meta: update links --- README.rst | 2 +- docs/conf.py | 2 +- topgg/http.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 5bdddce6..69ab58b2 100644 --- a/README.rst +++ b/README.rst @@ -27,7 +27,7 @@ Install from source .. code:: bash - pip3 install git+https://github.com/top-gg/python-sdk/ + pip3 install git+https://github.com/top-gg-community/python-sdk/ Documentation ------------- diff --git a/docs/conf.py b/docs/conf.py index 2d368576..ca87da68 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,7 +50,7 @@ autodoc_member_order = "groupwise" extlinks = { - "issue": ("https://github.com/top-gg/python-sdk/issues/%s", "GH-"), + "issue": ("https://github.com/top-gg-community/python-sdk/issues/%s", "GH-"), } intersphinx_mapping = { diff --git a/topgg/http.py b/topgg/http.py index 08160d67..6bd967c5 100644 --- a/topgg/http.py +++ b/topgg/http.py @@ -86,7 +86,7 @@ def __init__( [self.global_rate_limiter, self.bot_rate_limiter] ) self.user_agent = ( - f"topggpy (https://github.com/top-gg/python-sdk {__version__}) Python/" + f"topggpy (https://github.com/top-gg-community/python-sdk {__version__}) Python/" f"{sys.version_info[0]}.{sys.version_info[1]} aiohttp/{aiohttp.__version__}" ) From 28c56e44fb2feea9f947d5b84cb51260dd9d0acc Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 26 Mar 2024 18:32:38 +0700 Subject: [PATCH 003/126] meta: make the min supported ver 3.8 --- .github/workflows/python-package.yml | 2 +- .github/workflows/python-publish.yml | 2 +- .readthedocs.yml | 2 +- README.rst | 2 +- setup.py | 7 ++++--- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 60b37a8f..4d3db9c1 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ 3.6, 3.7, 3.8, 3.9 ] + python-version: [ 3.8, 3.9, 3.10, 3.11, 3.12 ] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 1eba4d89..2208dc39 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: '3.7' + python-version: '3.8' - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.readthedocs.yml b/.readthedocs.yml index 388f9a1f..35fe3221 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -7,7 +7,7 @@ build: image: latest python: - version: 3.8 + version: 3.12 install: - requirements: requirements.txt - requirements: requirements-docs.txt diff --git a/README.rst b/README.rst index 69ab58b2..8860689d 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,7 @@ Top.gg Python Library .. image:: https://img.shields.io/pypi/v/topggpy.svg :target: https://pypi.python.org/pypi/topggpy - :alt: View on PyPi + :alt: View on PyPI .. image:: https://img.shields.io/pypi/pyversions/topggpy.svg :target: https://pypi.python.org/pypi/topggpy :alt: v1.0.0 diff --git a/setup.py b/setup.py index 36733afb..8dc72dfd 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ long_description=readme, package_data={"topgg": ["py.typed"]}, include_package_data=True, - python_requires=">= 3.6", + python_requires=">= 3.8", install_requires=requirements, keywords="discord bot server list discordservers serverlist discordbots botlist topgg top.gg", classifiers=[ @@ -52,10 +52,11 @@ "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Internet", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules", From 957af685a0de676febbc7539d783cd086e9b017f Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 26 Mar 2024 18:35:28 +0700 Subject: [PATCH 004/126] fix: python 3.12 support --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9ad05803..1b80c609 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -aiohttp>=3.6.0,<3.9.0 \ No newline at end of file +aiohttp>=3.9.0 \ No newline at end of file From 3300b504aeffa72384b75cc5dde706095d68fa70 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 26 Mar 2024 18:36:50 +0700 Subject: [PATCH 005/126] fix: why does it say 3.1 :skull: --- .github/workflows/python-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 4d3db9c1..bea34d1f 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ 3.8, 3.9, 3.10, 3.11, 3.12 ] + python-version: [ 3.8, 3.9, '3.10', 3.11, 3.12 ] steps: - uses: actions/checkout@v2 From c2c7a691b1703181a1ea25980daa7e6481cda69d Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 26 Mar 2024 18:49:46 +0700 Subject: [PATCH 006/126] fix: migrate from setup.py to pyproject.toml --- pyproject.toml | 35 +++++++++++++++++++++++ requirements-docs.txt | 2 ++ setup.py | 65 ------------------------------------------- 3 files changed, 37 insertions(+), 65 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..6e789b6f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["setuptools"] + +[project] +name = "topggpy" +version = "2.0.1" +description = "A simple API wrapper for Top.gg written in Python." +readme = "README.rst" +license = { text = "MIT" } +authors = [{ name = "Assanali Mukhanov" }, { name = "Norizon" }, { name = "Top.gg" }] +keywords = ["discord", "bot", "topgg", "top.gg"] +dependencies = ["aiohttp>=3.9.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.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Utilities" +] +requires-python = ">=3.8" + +[project.urls] +Homepage = "https://topggpy.readthedocs.io/en/stable/" +Documentation = "https://topggpy.readthedocs.io/en/stable/" +Repository = "https://github.com/top-gg-community/python-sdk" \ No newline at end of file diff --git a/requirements-docs.txt b/requirements-docs.txt index e75c15e9..b90afd89 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,2 +1,4 @@ sphinx insegel +sphinxcontrib-napoleon +sphinx-rtd-dark-mode \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 8dc72dfd..00000000 --- a/setup.py +++ /dev/null @@ -1,65 +0,0 @@ -import os -import pathlib -import re -import types - -from setuptools import find_packages, setup - -HERE = pathlib.Path(__file__).parent - -txt = (HERE / "topgg" / "__init__.py").read_text("utf-8") - -groups = {} - -for match in re.finditer(r'__(?P.*)__\s*=\s*"(?P[^"]+)"\r?', txt): - group = match.groupdict() - groups[group["identifier"]] = group["value"] - -metadata = types.SimpleNamespace(**groups) - -on_rtd = os.getenv("READTHEDOCS") == "True" - -with open("requirements.txt") as f: - requirements = f.read().splitlines() - -if on_rtd: - requirements.append("sphinxcontrib-napoleon") - requirements.append("sphinx-rtd-dark-mode") - -with open("README.rst") as f: - readme = f.read() - -setup( - name="topggpy", - author=f"{metadata.author}, Top.gg", - author_email="shivaco.osu@gmail.com", - maintainer=f"{metadata.maintainer}, Top.gg", - url="https://github.com/top-gg/python-sdk", - version=metadata.version, - packages=find_packages(), - license=metadata.license, - description="A simple API wrapper for Top.gg written in Python.", - long_description=readme, - package_data={"topgg": ["py.typed"]}, - include_package_data=True, - python_requires=">= 3.8", - install_requires=requirements, - keywords="discord bot server list discordservers serverlist discordbots botlist topgg top.gg", - 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.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Internet", - "Topic :: Software Development :: Libraries", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Utilities", - ], -) From ee2ec2e241a24f1ea51423fca6b6d6c38138903e Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 26 Mar 2024 18:50:09 +0700 Subject: [PATCH 007/126] meta: bump version --- topgg/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/topgg/__init__.py b/topgg/__init__.py index 1a9025eb..eed4b284 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -12,7 +12,7 @@ __author__ = "Assanali Mukhanov" __maintainer__ = "Norizon" __license__ = "MIT" -__version__ = "2.0.0a1" +__version__ = "2.0.1" from .autopost import * from .client import * From 472f69d6b194e79682b63c4eb4fe722170387cc7 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 26 Mar 2024 18:56:43 +0700 Subject: [PATCH 008/126] ci: support pyproject.toml --- .github/workflows/python-package.yml | 2 +- .github/workflows/python-publish.yml | 31 ---------------------------- 2 files changed, 1 insertion(+), 32 deletions(-) delete mode 100644 .github/workflows/python-publish.yml diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index bea34d1f..c24b9baf 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -29,7 +29,7 @@ jobs: path: "requirements-dev.txt" - name: Install itself run: | - python setup.py install + python -m pip install . - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml deleted file mode 100644 index 2208dc39..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.8' - - 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/* From 5ee4fece90c3e5d05fc504ca5536f9283e9b0796 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 26 Mar 2024 19:00:48 +0700 Subject: [PATCH 009/126] feat: add python-publish.yml again with PyPI token support --- .github/workflows/python-publish.yml | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/python-publish.yml diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 00000000..5ff91b04 --- /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.12' + - 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: '__token__' + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* \ No newline at end of file From 82187cb79f15b742c669174c20aac6f726a33565 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 26 Mar 2024 19:31:53 +0700 Subject: [PATCH 010/126] meta: update .gitignore [skip ci] --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3d7edd2f..1d889220 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -dblpy.egg-info/ +topggpy.egg-info/ topggpy.egg-info/ topgg/__pycache__/ build/ From f4e83c06cc4e8d81298e0438ce83867f682f922b Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 27 Mar 2024 12:37:03 +0700 Subject: [PATCH 011/126] meta: change maintainers --- LICENSE | 1 + pyproject.toml | 2 +- topgg/__init__.py | 4 ++-- topgg/py.typed | 0 4 files changed, 4 insertions(+), 3 deletions(-) delete mode 100644 topgg/py.typed diff --git a/LICENSE b/LICENSE index 96aaaf80..066ec504 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,5 @@ Copyright 2021 Assanali Mukhanov & Top.gg +Copyright 2024 null & 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: diff --git a/pyproject.toml b/pyproject.toml index 6e789b6f..fc1cdce2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ version = "2.0.1" description = "A simple API wrapper for Top.gg written in Python." readme = "README.rst" license = { text = "MIT" } -authors = [{ name = "Assanali Mukhanov" }, { name = "Norizon" }, { name = "Top.gg" }] +authors = [{ name = "null8626" }, { name = "Top.gg" }] keywords = ["discord", "bot", "topgg", "top.gg"] dependencies = ["aiohttp>=3.9.0"] classifiers = [ diff --git a/topgg/__init__.py b/topgg/__init__.py index eed4b284..d54460a1 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -5,12 +5,12 @@ ~~~~~~~~~~~~~~~~~~~~~~~~~ A basic wrapper for the Top.gg API. :copyright: (c) 2021 Assanali Mukhanov & Top.gg +:copyright: (c) 2024 null & Top.gg :license: MIT, see LICENSE for more details. """ __title__ = "topggpy" -__author__ = "Assanali Mukhanov" -__maintainer__ = "Norizon" +__author__ = "null8626" __license__ = "MIT" __version__ = "2.0.1" diff --git a/topgg/py.typed b/topgg/py.typed deleted file mode 100644 index e69de29b..00000000 From 533295ae1b11c70b9da84b2422edc426f061f499 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 27 Mar 2024 12:37:24 +0700 Subject: [PATCH 012/126] doc: trauma --- README.rst | 8 -------- 1 file changed, 8 deletions(-) diff --git a/README.rst b/README.rst index 8860689d..041b963a 100644 --- a/README.rst +++ b/README.rst @@ -17,18 +17,10 @@ A simple API wrapper for `Top.gg `_ written in Python, supporti Installation ------------ -Install via pip (recommended) - .. code:: bash pip3 install topggpy -Install from source - -.. code:: bash - - pip3 install git+https://github.com/top-gg-community/python-sdk/ - Documentation ------------- From df9b489ad9b6c938d0958e1d93288cca09b2eb77 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 27 Mar 2024 13:07:46 +0700 Subject: [PATCH 013/126] meta: remove duplicated path in .gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1d889220..41a61499 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ topggpy.egg-info/ -topggpy.egg-info/ topgg/__pycache__/ build/ dist/ From eda566e9234883ed0ca9d8803bda301a4092d425 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 27 Mar 2024 13:31:06 +0700 Subject: [PATCH 014/126] meta: update LICENSE --- LICENSE | 2 +- topgg/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index 066ec504..c4cd04ce 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ Copyright 2021 Assanali Mukhanov & Top.gg -Copyright 2024 null & Top.gg +Copyright 2024 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: diff --git a/topgg/__init__.py b/topgg/__init__.py index d54460a1..3c5f8451 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -5,7 +5,7 @@ ~~~~~~~~~~~~~~~~~~~~~~~~~ A basic wrapper for the Top.gg API. :copyright: (c) 2021 Assanali Mukhanov & Top.gg -:copyright: (c) 2024 null & Top.gg +:copyright: (c) 2024 null8626 & Top.gg :license: MIT, see LICENSE for more details. """ From e1460488291baa81cced9330072f66680e5a5e30 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 28 Mar 2024 07:55:47 +0700 Subject: [PATCH 015/126] *: use ruff, remove need for default_bot_id, deprecate get_bots, make def_avatar optional --- .gitattributes | 1 + .github/workflows/python-package.yml | 7 +- .isort.cfg | 3 - MANIFEST.in | 19 +- docs/whats_new.rst | 12 ++ examples/discordpy_example/__main__.py | 1 - examples/hikari_example/__main__.py | 1 - examples/hikari_example/callbacks/autopost.py | 5 +- examples/hikari_example/callbacks/webhook.py | 1 + requirements-dev.txt | 22 +-- ruff.toml | 9 + tests/test_autopost.py | 176 +++++++++--------- tests/test_client.py | 48 ++--- tests/test_data_container.py | 16 +- tests/test_ratelimiter.py | 56 +++--- tests/test_type.py | 19 +- tests/test_webhook.py | 156 ++++++++-------- topgg/autopost.py | 42 ++--- topgg/client.py | 122 ++++-------- topgg/data.py | 18 +- topgg/http.py | 29 ++- topgg/ratelimiter.py | 7 +- topgg/types.py | 2 +- topgg/webhook.py | 59 +++--- 24 files changed, 352 insertions(+), 479 deletions(-) create mode 100644 .gitattributes delete mode 100644 .isort.cfg create mode 100644 ruff.toml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..44b4224b --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* eol=lf \ No newline at end of file diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index c24b9baf..473cba85 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -30,12 +30,9 @@ jobs: - name: Install itself run: | python -m pip install . - - name: Lint with flake8 + - name: Lint with ruff 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 + ruff check - name: Test with pytest run: | pytest diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index 317f1d34..00000000 --- a/.isort.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[settings] -profile=black -multi_line_output=3 \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index dc068afa..5dec6b10 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,16 @@ -include LICENSE -include requirements.txt -include README.rst +prune .github +prune .ruff_cache +prune docs +prune examples +prune scripts +prune tests +exclude .gitattributes +exclude .gitignore +exclude .readthedocs.yml +exclude mypy.ini +exclude pytest.ini +exclude requirements-dev.txt +exclude requirements-docs.txt +exclude ruff.toml +exclude ISSUE_TEMPLATE.md +exclude PULL_REQUEST_TEMPLATE.md \ No newline at end of file diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 8fc1d0e9..23e3ffe3 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -8,6 +8,18 @@ What's New This page keeps a detailed human friendly rendering of what's new and changed in specific versions. +v2.0.1 +====== +* Added Python 3.12 support +* Dropped Python 3.6 and 3.7 support +* Removed the need to manually set a ``default_bot_id`` property +* :attr:`BotData.def_avatar` is now an optional string +* :meth:`DBLClient.get_bots` is now deprecated +* :meth:`DBLClient.get_guild_count` no longer accepts a ``bot_id`` argument +* :meth:`DBLClient.get_bot_votes` no longer raises a ``ClientException`` without a ``default_bot_id`` property +* :meth:`DBLClient.get_bot_info` no longer raises a ``ClientException`` without a ``default_bot_id`` property +* :meth:`DBLClient.generate_widget`` no longer raises a ``ClientException`` without a ``default_bot_id`` property + v2.0.0a ======= * :obj:`~.DBLClient` now doesn't take in ``discord.Client`` instance diff --git a/examples/discordpy_example/__main__.py b/examples/discordpy_example/__main__.py index f1c1f6dd..67e58232 100644 --- a/examples/discordpy_example/__main__.py +++ b/examples/discordpy_example/__main__.py @@ -39,7 +39,6 @@ @client.event async def on_ready(): assert client.user is not None - dblclient.default_bot_id = client.user.id # if it's ready, then the event loop's run, # hence it's safe starting the autopost here diff --git a/examples/hikari_example/__main__.py b/examples/hikari_example/__main__.py index 0bef502f..857329c8 100644 --- a/examples/hikari_example/__main__.py +++ b/examples/hikari_example/__main__.py @@ -40,7 +40,6 @@ async def on_started(event: hikari.StartedEvent): me: hikari.OwnUser = event.app.get_me() assert me is not None - dblclient.default_bot_id = me.id # since StartedEvent is a lifetime event # this event will only get dispatched once diff --git a/examples/hikari_example/callbacks/autopost.py b/examples/hikari_example/callbacks/autopost.py index 3ac467b3..6bab89f2 100644 --- a/examples/hikari_example/callbacks/autopost.py +++ b/examples/hikari_example/callbacks/autopost.py @@ -29,6 +29,7 @@ _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 @@ -56,6 +57,4 @@ def on_autopost_error( def stats(app: hikari.GatewayBot = topgg.data(hikari.GatewayBot)): - return topgg.StatsWrapper( - guild_count=len(app.cache.get_guilds_view()), shard_count=app.shard_count - ) + return topgg.StatsWrapper(guild_count=len(app.cache.get_guilds_view()), shard_count=app.shard_count) diff --git a/examples/hikari_example/callbacks/webhook.py b/examples/hikari_example/callbacks/webhook.py index 50c53a73..49daedaa 100644 --- a/examples/hikari_example/callbacks/webhook.py +++ b/examples/hikari_example/callbacks/webhook.py @@ -31,6 +31,7 @@ _LOGGER = logging.getLogger("callbacks.webhook") + # this can be async too! @topgg.endpoint("/dblwebhook", topgg.WebhookType.BOT, "youshallnotpass") async def endpoint( diff --git a/requirements-dev.txt b/requirements-dev.txt index e5d5d951..36ad3056 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,13 +1,9 @@ -# Formatting -git+https://github.com/timothycrosley/isort -git+https://github.com/psf/black - -# Unit Testing -mock -pytest -pytest-asyncio -pytest-mock -pytest-cov - -# Linting -flake8 +# Formatting and Linting +ruff + +# Unit Testing +mock +pytest +pytest-asyncio +pytest-mock +pytest-cov \ No newline at end of file diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..650555f8 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,9 @@ +line-length = 120 + +[format] +docstring-code-format = true +docstring-code-line-length = 120 +line-ending = "lf" + +[lint] +ignore = ["E722", "F401", "F403"] \ No newline at end of file diff --git a/tests/test_autopost.py b/tests/test_autopost.py index 7cf39c14..07d60a50 100644 --- a/tests/test_autopost.py +++ b/tests/test_autopost.py @@ -1,92 +1,84 @@ -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 ServerError, TopGGException, Unauthorized - - -@pytest.fixture -def session() -> ClientSession: - return mock.Mock(ClientSession) - - -@pytest.fixture -def autopost(session: ClientSession) -> AutoPoster: - return AutoPoster(DBLClient("", 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=Unauthorized(response, {}) - ) - - callback = mock.Mock() - autopost = DBLClient("", session=session).autopost().stats(callback) - assert isinstance(autopost, AutoPoster) - assert not isinstance(autopost.stats()(callback), AutoPoster) - - with pytest.raises(Unauthorized): - 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 = ServerError(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 ServerError, TopGGException, Unauthorized + + +@pytest.fixture +def session() -> ClientSession: + return mock.Mock(ClientSession) + + +@pytest.fixture +def autopost(session: ClientSession) -> AutoPoster: + return AutoPoster(DBLClient("", 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=Unauthorized(response, {})) + + callback = mock.Mock() + autopost = DBLClient("", session=session).autopost().stats(callback) + assert isinstance(autopost, AutoPoster) + assert not isinstance(autopost.stats()(callback), AutoPoster) + + with pytest.raises(Unauthorized): + 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 = ServerError(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 fb634ead..fbd7a196 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,3 @@ -import typing as t - import mock import pytest from aiohttp import ClientSession @@ -8,6 +6,9 @@ from topgg import errors +MOCK_TOKEN = "amogus.eyJpZCI6IjEwMjY1MjU1NjgzNDQyNjQ3MjQiLCJib3QiOnRydWUsImlhdCI6MTY5OTk4NDYyM30.amogus" + + @pytest.fixture def session() -> ClientSession: return mock.Mock(ClientSession) @@ -15,14 +16,14 @@ def session() -> ClientSession: @pytest.fixture def client() -> topgg.DBLClient: - client = topgg.DBLClient(token="TOKEN", default_bot_id=1234) + client = topgg.DBLClient(token=MOCK_TOKEN) client.http = mock.Mock(topgg.http.HTTPClient) return client @pytest.mark.asyncio async def test_HTTPClient_with_external_session(session: ClientSession): - http = topgg.http.HTTPClient("TOKEN", session=session) + http = topgg.http.HTTPClient(MOCK_TOKEN, session=session) assert not http._own_session await http.close() session.close.assert_not_called() @@ -30,26 +31,16 @@ async def test_HTTPClient_with_external_session(session: ClientSession): @pytest.mark.asyncio async def test_HTTPClient_with_no_external_session(session: ClientSession): - http = topgg.http.HTTPClient("TOKEN") + http = topgg.http.HTTPClient(MOCK_TOKEN) http.session = session assert http._own_session await http.close() session.close.assert_called_once() -@pytest.mark.asyncio -async def test_DBLClient_get_bot_votes_with_no_default_bot_id(): - client = topgg.DBLClient("TOKEN") - with pytest.raises( - errors.ClientException, - match="you must set default_bot_id when constructing the client.", - ): - await client.get_bot_votes() - - @pytest.mark.asyncio async def test_DBLClient_post_guild_count_with_no_args(): - client = topgg.DBLClient("TOKEN", default_bot_id=1234) + client = topgg.DBLClient(MOCK_TOKEN) with pytest.raises(TypeError, match="stats or guild_count must be provided."): await client.post_guild_count() @@ -67,20 +58,9 @@ async def test_DBLClient_post_guild_count_with_no_args(): ), ], ) -@pytest.mark.asyncio -async def test_DBLClient_get_guild_count_with_no_id( - method: t.Callable, kwargs: t.Dict[str, t.Any] -): - client = topgg.DBLClient("TOKEN") - with pytest.raises( - errors.ClientException, match="bot_id or default_bot_id is unset." - ): - await method(client, **kwargs) - - @pytest.mark.asyncio async def test_closed_DBLClient_raises_exception(): - client = topgg.DBLClient("TOKEN") + client = topgg.DBLClient(MOCK_TOKEN) assert not client.is_closed await client.close() assert client.is_closed @@ -88,6 +68,11 @@ async def test_closed_DBLClient_raises_exception(): await client.get_weekend_status() +@pytest.mark.asyncio +async def test_closed_DBLClient_bot_id(): + assert client.bot_id == 1026525568344264724 + + @pytest.mark.asyncio async def test_DBLClient_get_weekend_status(client: topgg.DBLClient): client.http.get_weekend_status = mock.AsyncMock() @@ -116,13 +101,6 @@ async def test_DBLClient_get_bot_votes(client: topgg.DBLClient): client.http.get_bot_votes.assert_called_once() -@pytest.mark.asyncio -async def test_DBLClient_get_bots(client: topgg.DBLClient): - client.http.get_bots = mock.AsyncMock(return_value={"results": []}) - await client.get_bots() - client.http.get_bots.assert_called_once() - - @pytest.mark.asyncio async def test_DBLClient_get_user_info(client: topgg.DBLClient): client.http.get_user_info = mock.AsyncMock(return_value={}) diff --git a/tests/test_data_container.py b/tests/test_data_container.py index 978574fb..f89466ea 100644 --- a/tests/test_data_container.py +++ b/tests/test_data_container.py @@ -13,20 +13,13 @@ def data_container() -> DataContainerMixin: return dc -async def _async_callback( - text: str = data(str), number: int = data(int), mapping: dict = data(dict) -): - ... +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 _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 @@ -42,8 +35,7 @@ async def test_data_container_invoke_sync_callback(data_container: DataContainer 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.", + match=" already exists. If you wish to override it, " "pass True into the override parameter.", ): data_container.set_data("TEST") diff --git a/tests/test_ratelimiter.py b/tests/test_ratelimiter.py index f1fbed6b..9153b3a1 100644 --- a/tests/test_ratelimiter.py +++ b/tests/test_ratelimiter.py @@ -1,28 +1,28 @@ -import pytest - -from topgg.ratelimiter import AsyncRateLimiter - -n = period = 10 - - -@pytest.fixture -def limiter() -> AsyncRateLimiter: - return AsyncRateLimiter(max_calls=n, period=period) - - -@pytest.mark.asyncio -async def test_AsyncRateLimiter_calls(limiter: AsyncRateLimiter) -> None: - for _ in range(n): - async with limiter: - pass - - assert len(limiter.calls) == limiter.max_calls == n - - -@pytest.mark.asyncio -async def test_AsyncRateLimiter_timespan_property(limiter: AsyncRateLimiter) -> None: - for _ in range(n): - async with limiter: - pass - - assert limiter._timespan < period +import pytest + +from topgg.ratelimiter import AsyncRateLimiter + +n = period = 10 + + +@pytest.fixture +def limiter() -> AsyncRateLimiter: + return AsyncRateLimiter(max_calls=n, period=period) + + +@pytest.mark.asyncio +async def test_AsyncRateLimiter_calls(limiter: AsyncRateLimiter) -> None: + for _ in range(n): + async with limiter: + pass + + assert len(limiter.calls) == limiter.max_calls == n + + +@pytest.mark.asyncio +async def test_AsyncRateLimiter_timespan_property(limiter: AsyncRateLimiter) -> 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..8cea66c5 100644 --- a/tests/test_type.py +++ b/tests/test_type.py @@ -143,12 +143,7 @@ def test_widget_options_fields(widget_options: types.WidgetOptions) -> None: 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 widget_options.get(attr) == widget_options[attr] == widget_options[attr] == getattr(widget_options, attr) def test_vote_data_fields(vote_data: types.VoteDataDict) -> None: @@ -165,11 +160,7 @@ def test_bot_vote_data_fields(bot_vote_data: types.BotVoteData) -> None: 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 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: @@ -178,11 +169,7 @@ def test_server_vote_data_fields(server_vote_data: types.BotVoteData) -> None: 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 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: diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 8ef3c71d..db1da09d 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -1,80 +1,76 @@ -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}, 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() diff --git a/topgg/autopost.py b/topgg/autopost.py index 3bfe4afa..96aa9888 100644 --- a/topgg/autopost.py +++ b/topgg/autopost.py @@ -78,17 +78,13 @@ def __init__(self, client: "DBLClient") -> None: def _default_error_handler(self, exception: Exception) -> None: print("Ignoring exception in auto post loop:", file=sys.stderr) - traceback.print_exception( - type(exception), exception, exception.__traceback__, 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: """ @@ -103,15 +99,15 @@ def on_success(self, callback: t.Any = None) -> t.Any: # 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(): - ... + def autopost(): ... + # Used as decorator factory, the decorated function will still be the function itself. @autopost.on_success() - def on_success(): - ... + def on_success(): ... """ if callback is not None: self._success = callback @@ -124,12 +120,10 @@ def decorator(callback: CallbackT) -> CallbackT: 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: """ @@ -148,15 +142,15 @@ def on_error(self, callback: t.Any = None) -> t.Any: # The following are valid. autopost = dblclient.autopost().on_error(lambda exc: print("Failed posting stats!", exc)) + # Used as decorator, the decorated function will become the AutoPoster object. @autopost.on_error - def autopost(exc: Exception): - ... + 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): - ... + def on_error(exc: Exception): ... """ if callback is not None: self._error = callback @@ -169,12 +163,10 @@ def decorator(callback: CallbackT) -> CallbackT: 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: """ @@ -295,9 +287,7 @@ def start(self) -> "asyncio.Task[None]": If there's no callback provided or the autopost is already running. """ if not hasattr(self, "_stats"): - raise errors.TopGGException( - "you must provide a callback that returns the stats." - ) + raise errors.TopGGException("you must provide a callback that returns the stats.") if self.is_running: raise errors.TopGGException("the autopost is already running.") diff --git a/topgg/client.py b/topgg/client.py index 0f1a72db..ff7717eb 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -3,6 +3,7 @@ # The MIT License (MIT) # Copyright (c) 2021 Assanali Mukhanov +# Copyright (c) 2024 null8626 # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), @@ -24,7 +25,11 @@ __all__ = ["DBLClient"] +import base64 +import json +import re import typing as t +import warnings import aiohttp @@ -45,28 +50,35 @@ class DBLClient(DataContainerMixin): token (:obj:`str`): Your bot's Top.gg API Token. Keyword Args: - default_bot_id (:obj:`typing.Optional` [ :obj:`int` ]) - The default bot_id. You can override this by passing it when calling a method. session (:class:`aiohttp.ClientSession`) An `aiohttp session`_ to use for requests to the API. **kwargs: Arbitrary kwargs to be passed to :class:`aiohttp.ClientSession` if session was not provided. """ - __slots__ = ("http", "default_bot_id", "_token", "_is_closed", "_autopost") + __slots__ = ("http", "bot_id", "_token", "_is_closed", "_autopost") http: HTTPClient def __init__( self, token: str, *, - default_bot_id: t.Optional[int] = None, session: t.Optional[aiohttp.ClientSession] = None, **kwargs: t.Any, ) -> None: super().__init__() self._token = token - self.default_bot_id = default_bot_id + + try: + encoded_json = re.sub(r"[^a-zA-Z0-9\+\/]+", "", token.split(".")[1]) + missing_padding = len(encoded_json) % 4 + if missing_padding: + encoded_json += "=" * (4 - missing_padding) + + self.bot_id = int(json.loads(base64.b64decode(encoded_json))["id"]) + except: + raise errors.ClientException("invalid token.") + self._is_closed = False if session is not None: self.http = HTTPClient(token, session=session) @@ -83,13 +95,6 @@ async def _ensure_session(self) -> None: if not hasattr(self, "http"): self.http = HTTPClient(self._token, session=None) - def _validate_and_get_bot_id(self, bot_id: t.Optional[int]) -> int: - bot_id = bot_id or self.default_bot_id - if bot_id is None: - raise errors.ClientException("bot_id or default_bot_id is unset.") - - return bot_id - async def get_weekend_status(self) -> bool: """Gets weekend status from Top.gg. @@ -105,8 +110,7 @@ async def get_weekend_status(self) -> bool: return data["is_weekend"] @t.overload - async def post_guild_count(self, stats: types.StatsWrapper) -> None: - ... + async def post_guild_count(self, stats: types.StatsWrapper) -> None: ... @t.overload async def post_guild_count( @@ -115,8 +119,7 @@ async def post_guild_count( guild_count: t.Union[int, t.List[int]], shard_count: t.Optional[int] = None, shard_id: t.Optional[int] = None, - ) -> None: - ... + ) -> None: ... async def post_guild_count( self, @@ -161,28 +164,19 @@ async def post_guild_count( await self._ensure_session() await self.http.post_guild_count(guild_count, shard_count, shard_id) - async def get_guild_count( - self, bot_id: t.Optional[int] = None - ) -> types.BotStatsData: - """Gets a bot's guild count and shard info from Top.gg. - - Args: - bot_id (int) - ID of the bot you want to look up. Defaults to the provided Client object. + async def get_guild_count(self) -> types.BotStatsData: + """Gets this bot's guild count and shard info from Top.gg. Returns: :obj:`~.types.BotStatsData`: The guild count and shards of a bot on Top.gg. Raises: - :obj:`~.errors.ClientException` - If neither bot_id or default_bot_id was set. :obj:`~.errors.ClientStateException` If the client has been closed. """ - bot_id = self._validate_and_get_bot_id(bot_id) await self._ensure_session() - response = await self.http.get_guild_count(bot_id) + response = await self.http.get_guild_count(self.bot_id) return types.BotStatsData(**response) async def get_bot_votes(self) -> t.List[types.BriefUserData]: @@ -196,17 +190,11 @@ async def get_bot_votes(self) -> t.List[types.BriefUserData]: Users who voted for your bot. Raises: - :obj:`~.errors.ClientException` - If default_bot_id isn't provided when constructing the client. :obj:`~.errors.ClientStateException` If the client has been closed. """ - if not self.default_bot_id: - raise errors.ClientException( - "you must set default_bot_id when constructing the client." - ) await self._ensure_session() - response = await self.http.get_bot_votes(self.default_bot_id) + response = await self.http.get_bot_votes(self.bot_id) return [types.BriefUserData(**user) for user in response] async def get_bot_info(self, bot_id: t.Optional[int] = None) -> types.BotData: @@ -216,7 +204,7 @@ async def get_bot_info(self, bot_id: t.Optional[int] = None) -> types.BotData: Args: bot_id (int) - ID of the bot to look up. Defaults to the provided Client object. + ID of the bot to look up. Defaults to this bot's ID. Returns: :obj:`~.types.BotData`: @@ -224,14 +212,11 @@ async def get_bot_info(self, bot_id: t.Optional[int] = None) -> types.BotData: `here `_. Raises: - :obj:`~.errors.ClientException` - If neither bot_id or default_bot_id was set. :obj:`~.errors.ClientStateException` If the client has been closed. """ - bot_id = self._validate_and_get_bot_id(bot_id) await self._ensure_session() - response = await self.http.get_bot_info(bot_id) + response = await self.http.get_bot_info(bot_id or self.bot_id) return types.BotData(**response) async def get_bots( @@ -242,38 +227,16 @@ async def get_bots( search: t.Optional[t.Dict[str, t.Any]] = None, fields: t.Optional[t.List[str]] = None, ) -> types.DataDict[str, t.Any]: - """This function is a coroutine. - - Gets information about listed bots on Top.gg. - - Args: - limit (int) - The number of results to look up. Defaults to 50. Max 500 allowed. - offset (int) - The amount of bots to skip. Defaults to 0. - sort (str) - The field to sort by. Prefix with ``-`` to reverse the order. - search (:obj:`dict` [ :obj:`str`, :obj:`typing.Any` ]) - The search data. - fields (:obj:`list` [ :obj:`str` ]) - Fields to output. - - Returns: - :obj:`~.types.DataDict`: - Info on bots that match the search query on Top.gg. - - Raises: - :obj:`~.errors.ClientStateException` - If the client has been closed. - """ + """This function is deprecated.""" + + warnings.warn("get_bots is now deprecated.", DeprecationWarning) + sort = sort or "" search = search or {} fields = fields or [] await self._ensure_session() response = await self.http.get_bots(limit, offset, sort, search, fields) - response["results"] = [ - types.BotData(**bot_data) for bot_data in response["results"] - ] + response["results"] = [types.BotData(**bot_data) for bot_data in response["results"]] return types.DataDict(**response) async def get_user_info(self, user_id: int) -> types.UserData: @@ -308,18 +271,11 @@ async def get_user_vote(self, user_id: int) -> bool: :obj:`bool`: Info about the user's vote. Raises: - :obj:`~.errors.ClientException` - If default_bot_id isn't provided when constructing the client. :obj:`~.errors.ClientStateException` If the client has been closed. """ - if not self.default_bot_id: - raise errors.ClientException( - "you must set default_bot_id when constructing the client." - ) - await self._ensure_session() - data = await self.http.get_user_vote(self.default_bot_id, user_id) + data = await self.http.get_user_vote(self.bot_id, user_id) return bool(data["voted"]) def generate_widget(self, *, options: types.WidgetOptions) -> str: @@ -334,28 +290,22 @@ def generate_widget(self, *, options: types.WidgetOptions) -> str: str: Generated widget URL. Raises: - :obj:`~.errors.ClientException` - If bot_id or default_bot_id is unset. TypeError: If options passed is not of type WidgetOptions. """ if not isinstance(options, types.WidgetOptions): - raise TypeError( - "options argument passed to generate_widget must be of type WidgetOptions" - ) - - bot_id = options.id or self.default_bot_id - if bot_id is None: - raise errors.ClientException("bot_id or default_bot_id is unset.") + raise TypeError("options argument passed to generate_widget must be of type WidgetOptions") + bot_id = options.id or self.bot_id widget_query = f"noavatar={str(options.noavatar).lower()}" + for key, value in options.colors.items(): widget_query += f"&{key.lower()}{'' if key.lower().endswith('color') else 'color'}={value:x}" + widget_format = options.format widget_type = f"/{options.type}" if options.type else "" - url = f"""https://top.gg/api/widget{widget_type}/{bot_id}.{widget_format}?{widget_query}""" - return url + return f"https://top.gg/api/widget{widget_type}/{bot_id}.{widget_format}?{widget_query}" async def close(self) -> None: """Closes all connections.""" diff --git a/topgg/data.py b/topgg/data.py index 7126d3bf..baafde66 100644 --- a/topgg/data.py +++ b/topgg/data.py @@ -52,6 +52,7 @@ def data(type_: t.Type[T]) -> T: dblclient = topgg.DBLClient(TOKEN).set_data(client) autopost: topgg.AutoPoster = dblclient.autopost() + @autopost.stats() def get_stats(client: Client = topgg.data(Client)): return topgg.StatsWrapper(guild_count=len(client.guilds), shard_count=len(client.shards)) @@ -79,9 +80,7 @@ class DataContainerMixin: def __init__(self) -> None: self._data: t.Dict[t.Type, t.Any] = {type(self): self} - def set_data( - self: DataContainerT, data_: t.Any, *, override: bool = False - ) -> DataContainerT: + def set_data(self: DataContainerT, data_: t.Any, *, override: bool = False) -> DataContainerT: """ Sets data to be available in your functions. @@ -105,20 +104,16 @@ def set_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.""" return self._data.get(type_, default) - async def _invoke_callback( - self, callback: t.Callable[..., T], *args: t.Any, **kwargs: t.Any - ) -> T: + 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 @@ -128,8 +123,7 @@ async def _invoke_callback( signatures: t.Dict[str, Data] = { k: v.default for k, v in parameters.items() - if v.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - and isinstance(v.default, Data) + if v.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD and isinstance(v.default, Data) } for k, v in signatures.items(): diff --git a/topgg/http.py b/topgg/http.py index 6bd967c5..9e34242d 100644 --- a/topgg/http.py +++ b/topgg/http.py @@ -3,6 +3,7 @@ # The MIT License (MIT) # Copyright (c) 2021 Assanali Mukhanov +# Copyright (c) 2024 null8626 # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), @@ -28,6 +29,7 @@ import json import logging import sys +import warnings from datetime import datetime from typing import Any, Coroutine, Dict, Iterable, List, Optional, Sequence, Union, cast @@ -76,15 +78,9 @@ def __init__( self.token = token self._own_session = session is None self.session: aiohttp.ClientSession = session or aiohttp.ClientSession(**kwargs) - self.global_rate_limiter = AsyncRateLimiter( - max_calls=99, period=1, callback=_rate_limit_handler - ) - self.bot_rate_limiter = AsyncRateLimiter( - max_calls=59, period=60, callback=_rate_limit_handler - ) - self.rate_limiters = AsyncRateLimiterManager( - [self.global_rate_limiter, self.bot_rate_limiter] - ) + self.global_rate_limiter = AsyncRateLimiter(max_calls=99, period=1, callback=_rate_limit_handler) + self.bot_rate_limiter = AsyncRateLimiter(max_calls=59, period=60, callback=_rate_limit_handler) + self.rate_limiters = AsyncRateLimiterManager([self.global_rate_limiter, self.bot_rate_limiter]) self.user_agent = ( f"topggpy (https://github.com/top-gg-community/python-sdk {__version__}) Python/" f"{sys.version_info[0]}.{sys.version_info[1]} aiohttp/{aiohttp.__version__}" @@ -92,11 +88,7 @@ def __init__( async def request(self, method: str, endpoint: str, **kwargs: Any) -> dict: """Handles requests to the API.""" - rate_limiters = ( - self.rate_limiters - if endpoint.startswith("/bots") - else self.global_rate_limiter - ) + rate_limiters = self.rate_limiters if endpoint.startswith("/bots") else self.global_rate_limiter url = f"{self.BASE}{endpoint}" if not self.token: @@ -210,7 +202,10 @@ def get_bots( search: Dict[str, str], fields: Sequence[str], ) -> Coroutine[Any, Any, dict]: - """Gets an object of bots on Top.gg.""" + """This function is now deprecated.""" + + warnings.warn("get_bots is now deprecated.", DeprecationWarning) + limit = min(limit, 500) fields = ", ".join(fields) search = " ".join([f"{field}: {value}" for field, value in search.items()]) @@ -240,9 +235,7 @@ async def _rate_limit_handler(until: float) -> None: """Handles the displayed message when we are ratelimited.""" duration = round(until - datetime.utcnow().timestamp()) mins = duration / 60 - fmt = ( - "We have exhausted a ratelimit quota. Retrying in %.2f seconds (%.3f minutes)." - ) + fmt = "We have exhausted a ratelimit quota. Retrying in %.2f seconds (%.3f minutes)." _LOGGER.warning(fmt, duration, mins) diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index 028a98ee..d2d75f79 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -102,9 +102,4 @@ async def __aexit__( exc_val: BaseException, exc_tb: TracebackType, ) -> None: - await asyncio.gather( - *[ - manager.__aexit__(exc_type, exc_val, exc_tb) - for manager in self.rate_limiters - ] - ) + await asyncio.gather(*[manager.__aexit__(exc_type, exc_val, exc_tb) for manager in self.rate_limiters]) diff --git a/topgg/types.py b/topgg/types.py index 2da13f95..8bfd0671 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -214,7 +214,7 @@ class BotData(DataDict[str, t.Any]): avatar: t.Optional[str] """The avatar hash of the bot.""" - def_avatar: str + def_avatar: t.Optional[str] """The avatar hash of the bot's default avatar.""" prefix: str diff --git a/topgg/webhook.py b/topgg/webhook.py index 4b94ec2b..28b92e91 100644 --- a/topgg/webhook.py +++ b/topgg/webhook.py @@ -74,12 +74,10 @@ def __init__(self) -> None: 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. @@ -109,9 +107,7 @@ def endpoint(self, endpoint_: t.Optional["WebhookEndpoint"] = None) -> t.Any: self.app.router.add_post( endpoint_._route, - self._get_handler( - endpoint_._type, endpoint_._auth, endpoint_._callback - ), + self._get_handler(endpoint_._type, endpoint_._auth, endpoint_._callback), ) return self @@ -150,9 +146,7 @@ async def close(self) -> None: await self._webserver.stop() self._is_running = False - def _get_handler( - self, type_: WebhookType, auth: str, callback: t.Callable[..., t.Any] - ) -> _HandlerT: + 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") @@ -225,12 +219,10 @@ def auth(self: T, auth_: str) -> T: 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: """ @@ -245,25 +237,21 @@ def callback(self, callback_: t.Any = None) -> t.Any: import topgg 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_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): - ... + def endpoint(vote_data: topgg.BotVoteData): ... + # Used as decorator factory, the decorated function will still be the function itself. @endpoint.callback() - def on_vote(vote_data: topgg.BotVoteData): - ... + def on_vote(vote_data: topgg.BotVoteData): ... + webhook_manager.endpoint(endpoint) """ @@ -286,25 +274,22 @@ class BoundWebhookEndpoint(WebhookEndpoint): import topgg webhook_manager = ( - topgg.WebhookManager() - .endpoint() - .type(topgg.WebhookType.BOT) - .route("/dblwebhook") - .auth("youshallnotpass") + topgg.WebhookManager().endpoint().type(topgg.WebhookType.BOT).route("/dblwebhook").auth("youshallnotpass") ) # 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): - ... + def endpoint(vote_data: topgg.BotVoteData): ... + # Used as decorator factory, the decorated function will still be the function itself. @endpoint.callback() - def on_vote(vote_data: topgg.BotVoteData): - ... + def on_vote(vote_data: topgg.BotVoteData): ... + endpoint.add_to_manager() """ @@ -330,9 +315,7 @@ def add_to_manager(self) -> WebhookManager: return self.manager -def endpoint( - route: str, type: WebhookType, auth: str = "" -) -> t.Callable[[t.Callable[..., t.Any]], WebhookEndpoint]: +def endpoint(route: str, type: WebhookType, auth: str = "") -> t.Callable[[t.Callable[..., t.Any]], WebhookEndpoint]: """ A decorator factory for instantiating WebhookEndpoint. @@ -353,13 +336,13 @@ def endpoint( import topgg + @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), - ): - ... + ): ... """ def decorator(func: t.Callable[..., t.Any]) -> WebhookEndpoint: From a4ce9f20612b01527e52c5eeed5f4cf8f5cf011b Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 28 Mar 2024 08:05:29 +0700 Subject: [PATCH 016/126] fix: let's see if this fixes it --- tests/test_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_client.py b/tests/test_client.py index fbd7a196..d70f241f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -50,6 +50,7 @@ async def test_DBLClient_post_guild_count_with_no_args(): [ (topgg.DBLClient.get_guild_count, {}), (topgg.DBLClient.get_bot_info, {}), + (topgg.DBLClient.get_weekend_status, {}), ( topgg.DBLClient.generate_widget, { From d4a9123a1dd0f74e54d9dabba3d0a4e248d66ed6 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 28 Mar 2024 08:10:41 +0700 Subject: [PATCH 017/126] fix: this should fix it --- tests/test_client.py | 14 -------------- topgg/client.py | 4 ++-- topgg/http.py | 4 ++-- 3 files changed, 4 insertions(+), 18 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index d70f241f..eaa18578 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -45,20 +45,6 @@ async def test_DBLClient_post_guild_count_with_no_args(): await client.post_guild_count() -@pytest.mark.parametrize( - "method, kwargs", - [ - (topgg.DBLClient.get_guild_count, {}), - (topgg.DBLClient.get_bot_info, {}), - (topgg.DBLClient.get_weekend_status, {}), - ( - topgg.DBLClient.generate_widget, - { - "options": topgg.types.WidgetOptions(), - }, - ), - ], -) @pytest.mark.asyncio async def test_closed_DBLClient_raises_exception(): client = topgg.DBLClient(MOCK_TOKEN) diff --git a/topgg/client.py b/topgg/client.py index ff7717eb..eddd8297 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -228,9 +228,9 @@ async def get_bots( fields: t.Optional[t.List[str]] = None, ) -> types.DataDict[str, t.Any]: """This function is deprecated.""" - + warnings.warn("get_bots is now deprecated.", DeprecationWarning) - + sort = sort or "" search = search or {} fields = fields or [] diff --git a/topgg/http.py b/topgg/http.py index 9e34242d..00717666 100644 --- a/topgg/http.py +++ b/topgg/http.py @@ -203,9 +203,9 @@ def get_bots( fields: Sequence[str], ) -> Coroutine[Any, Any, dict]: """This function is now deprecated.""" - + warnings.warn("get_bots is now deprecated.", DeprecationWarning) - + limit = min(limit, 500) fields = ", ".join(fields) search = " ".join([f"{field}: {value}" for field, value in search.items()]) From d3b48dcc12d636b0f109e67b0da620f771037b6c Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 28 Mar 2024 08:14:02 +0700 Subject: [PATCH 018/126] fix: AAAAAAAAAAHHHHHHH --- tests/test_autopost.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_autopost.py b/tests/test_autopost.py index 07d60a50..115297cc 100644 --- a/tests/test_autopost.py +++ b/tests/test_autopost.py @@ -10,6 +10,9 @@ from topgg.errors import ServerError, TopGGException, Unauthorized +MOCK_TOKEN = "amogus.eyJpZCI6IjEwMjY1MjU1NjgzNDQyNjQ3MjQiLCJib3QiOnRydWUsImlhdCI6MTY5OTk4NDYyM30.amogus" + + @pytest.fixture def session() -> ClientSession: return mock.Mock(ClientSession) @@ -17,7 +20,7 @@ def session() -> ClientSession: @pytest.fixture def autopost(session: ClientSession) -> AutoPoster: - return AutoPoster(DBLClient("", session=session)) + return AutoPoster(DBLClient(MOCK_TOKEN, session=session)) @pytest.mark.asyncio @@ -29,7 +32,7 @@ async def test_AutoPoster_breaks_autopost_loop_on_401(mocker: MockerFixture, ses mocker.patch("topgg.DBLClient.post_guild_count", side_effect=Unauthorized(response, {})) callback = mock.Mock() - autopost = DBLClient("", session=session).autopost().stats(callback) + autopost = DBLClient(MOCK_TOKEN, session=session).autopost().stats(callback) assert isinstance(autopost, AutoPoster) assert not isinstance(autopost.stats()(callback), AutoPoster) From e7bd0b8049e9cd66400ab1e06fd63c72bf0904ae Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 28 Mar 2024 08:16:11 +0700 Subject: [PATCH 019/126] fix: fix bot_id test --- tests/test_client.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_client.py b/tests/test_client.py index eaa18578..fa11c434 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -56,8 +56,12 @@ async def test_closed_DBLClient_raises_exception(): @pytest.mark.asyncio -async def test_closed_DBLClient_bot_id(): +async def test_DBLClient_bot_id(): + client = topgg.DBLClient(MOCK_TOKEN) + assert not client.is_closed assert client.bot_id == 1026525568344264724 + await client.close() + assert client.is_closed @pytest.mark.asyncio From 99d8ab7120ee499501fcc2a77f367fd358a19680 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 28 Mar 2024 12:55:48 +0700 Subject: [PATCH 020/126] refactor: collapse if-statement --- topgg/client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index eddd8297..cab34637 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -71,9 +71,7 @@ def __init__( try: encoded_json = re.sub(r"[^a-zA-Z0-9\+\/]+", "", token.split(".")[1]) - missing_padding = len(encoded_json) % 4 - if missing_padding: - encoded_json += "=" * (4 - missing_padding) + encoded_json += "=" * (4 - (len(encoded_json) % 4)) self.bot_id = int(json.loads(base64.b64decode(encoded_json))["id"]) except: From b6be4c4da92e18b15590b3e996a5a94f5b9e1cc1 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 28 Mar 2024 12:56:58 +0700 Subject: [PATCH 021/126] doc: update readme --- README.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 041b963a..99fa29e5 100644 --- a/README.rst +++ b/README.rst @@ -12,7 +12,7 @@ Top.gg Python Library :target: https://topggpy.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status -A simple API wrapper for `Top.gg `_ written in Python, supporting discord.py. +A simple API wrapper for `Top.gg `_ written in Python. Installation ------------ @@ -31,7 +31,6 @@ Features * POST server count * GET bot info, server count, upvote info -* GET all bots * GET user info * GET widgets (large and small) including custom ones. See `docs.top.gg `_ for more info. * GET weekend status From d2ae045179eb1d7e13868829a7427a221cae456d Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 28 Mar 2024 15:10:58 +0700 Subject: [PATCH 022/126] doc: documentation overhaul --- .gitignore | 11 +- docs/_static/css/custom.css | 7 -- docs/_static/favicon.ico | Bin 0 -> 15406 bytes docs/_static/img/favicon-16x16.png | Bin 1150 -> 0 bytes docs/_static/style.css | 48 ++++++++ docs/api.rst | 2 +- docs/api/autopost.rst | 6 +- docs/api/client.rst | 6 +- docs/api/data.rst | 6 +- docs/api/errors.rst | 9 +- docs/api/types.rst | 6 +- docs/api/webhook.rst | 6 +- docs/conf.py | 169 +++-------------------------- docs/index.rst | 56 ++++++++-- docs/repository.rst | 5 + docs/support.rst | 5 + docs/topgg.svg | 12 +- docs/whats_new.rst | 63 +++++------ requirements-docs.txt | 5 +- topgg/autopost.py | 14 +-- topgg/client.py | 22 ++-- topgg/data.py | 6 +- topgg/webhook.py | 8 +- 23 files changed, 203 insertions(+), 269 deletions(-) delete mode 100644 docs/_static/css/custom.css create mode 100644 docs/_static/favicon.ico delete mode 100644 docs/_static/img/favicon-16x16.png create mode 100644 docs/_static/style.css create mode 100644 docs/repository.rst create mode 100644 docs/support.rst diff --git a/.gitignore b/.gitignore index 41a61499..51f781c6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,9 @@ topggpy.egg-info/ -topgg/__pycache__/ build/ dist/ -/docs/_build -/docs/_templates -.vscode -/.idea/ -__pycache__ +docs/_build +docs/_templates +.vscode/ +.idea/ +**/__pycache__/ .coverage diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css deleted file mode 100644 index c7e6795e..00000000 --- a/docs/_static/css/custom.css +++ /dev/null @@ -1,7 +0,0 @@ -header #logo-container img { - height: 100px; -} - -#search input[type="text"] { - font-size: 1em; -} \ No newline at end of file diff --git a/docs/_static/favicon.ico b/docs/_static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ad108bb290ac60782ab9222a51717307e5772b1c GIT binary patch literal 15406 zcmeHOJ!lj`6rPwEK@{U37*RMyG=>P5+r0}SYQ#bWK@iW%LSk}%NkFg=Ec8UdBDDyD zDHSb(7FOB_Vre0YHn9j;NE8VP_!E=o_wH`uX7w(!JCj>ZX5lgW^WOWu`F3u0W_C}A zCebX~+62m&$h8QO5<QX^LX6^EJYKoJTZosNh3J3}LJ(0tkD_6=;p4O_+e4jf zg(PhWN}S^|4pGJCvkr~*9iX+Ud3^DuJjiiNE8e9E&G^Y`@fYIMcG00t(sC|!dFo&0 z@6u;^9oJUNT&UM=9M60G2a|Njd+gsw%xuP``>ZMUfBLU46)*j>E{$S;5jI~_j(2EU z=JJ-R@f-vGbAHRG=%D{xwc}!uR`^W_YVx0}b}a8hqn6yy#9+NJ?w{;t6=-w?`g>>_ z`li!f%cba8m}gNKEg09^pUHjnL9?*2yh}U7Z6Jezjr}%tN8!V_-ME7Lk@LR zvtlQ;2B`nIKM(g!#Wc10bq4M_{AM4aoGbly{*2G%IK5PIK2`i0dA zOx$}V?UxH*@=z(4qy8$m(r3Bwx&5mc`ma4`ucuUegZLDCsYS&~b4P(WrJ}#8pX>*1 zX%(;v)L#K!C&3b}I0v)qBoM)FRspMkRlq7>T!CDIPUIaL@f+qLBbbL>9Z1rd@>(V} z83#9zka^4bK#g*S=k%yc96l=zz#2=T$e-5;_}%0+uEixs!xN$LBzUTWFPCfX0aGBq0%po!;7v>K*zJcSw z+&OQZ`O9`OjeBxU)&cxKFqbeE5r5`7l}*rDZu{W>Eev;#iI6{`&A$MLcHE2ajmRII z`8&=uEKr~0t!tF}S9m-``PcWPUXHc)qvnscGf|7*QrDv9kKeA+JN|0*h@5}OepA-R z@(=gik^1SDR)2l{`S}&r3Jx9N@nEf0U$aj_q1{#@+U0w1X5M}?^Jd;##`@^B*%*yOY^0yDLB`lHflOp++i7Bq){+Bl zaGeIxJ%)e9^r(3}TDy1IO(D-uBE9N0<`Xx);z2R6k5cgP8;ZdL)RHlj!>15Ua;U_^ zuzFr20b~BmII78Lhu&%#%DwPwqJaF~I-1W}sA-AhHKY4vuucDEA#7DCMO|5fTQ{R5(yJ`Q4NN3L`QaNN&f9gqQbAjhu5w5@yr&)(^ z&2{A_$a`$oKi#gMpWL<&dH)9QlQieV%b6b!%P!LG#=Yyh1JbMmwYwP9ObW7hg2&US v9)*)#h~(SyuFyFs9PdCsZ+hKRi&Idd7szfcS;n^X-IeEGef#wv`0e}&td}C1 diff --git a/docs/_static/style.css b/docs/_static/style.css new file mode 100644 index 00000000..ee1e4dd5 --- /dev/null +++ b/docs/_static/style.css @@ -0,0 +1,48 @@ +body { + --color-link-underline: rgba(0, 0, 0, 0); + --color-link-underline--hover: var(--color-link); + --color-inline-code-background: rgba(0, 0, 0, 0); + --color-api-background-hover: var(--color-background-primary); + --color-highlight-on-target: var(--color-background-primary) !important; + + --font-stack: "Inter", sans-serif !important; + --font-stack--monospace: "Roboto Mono", monospace !important; +} + +aside.toc-drawer { + visibility: hidden; +} + +#furo-readthedocs-versions, .injected, .edit-this-page, .related-pages, .headerlink { + visibility: hidden; + user-select: none; +} + +dd dt { + color: var(--color-foreground-secondary); +} + +aside.toc-drawer .docutils:hover, .sidebar-brand-text:hover { + transition: 0.15s; + filter: opacity(75%); +} + +.highlight .c1, em { + font-style: normal !important; +} + +.highlight .nn { + text-decoration: none !important; +} + +h1 { + font-weight: 900; +} + +.sidebar-brand-text { + font-weight: bolder; +} + +.sidebar-scroll .reference.internal { + color: var(--color-brand-primary); +} \ No newline at end of file diff --git a/docs/api.rst b/docs/api.rst index 69691659..c1dc98ff 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,7 +1,7 @@ .. currentmodule:: topgg ############# -API Reference +API reference ############# The following section outlines the API of topggpy. diff --git a/docs/api/autopost.rst b/docs/api/autopost.rst index 668af79a..0151646d 100644 --- a/docs/api/autopost.rst +++ b/docs/api/autopost.rst @@ -1,6 +1,6 @@ -####################### -Auto-post API Reference -####################### +################## +Autopost reference +################## .. automodule:: topgg.autopost :members: diff --git a/docs/api/client.rst b/docs/api/client.rst index 1bac1971..1d8966e8 100644 --- a/docs/api/client.rst +++ b/docs/api/client.rst @@ -1,6 +1,6 @@ -#################### -Client API Reference -#################### +################ +Client reference +################ .. automodule:: topgg.client :members: diff --git a/docs/api/data.rst b/docs/api/data.rst index 3f10ff2e..090494ef 100644 --- a/docs/api/data.rst +++ b/docs/api/data.rst @@ -1,6 +1,6 @@ -################## -Data API Reference -################## +############## +Data reference +############## .. automodule:: topgg.data :members: diff --git a/docs/api/errors.rst b/docs/api/errors.rst index 804fdfa3..d54af678 100644 --- a/docs/api/errors.rst +++ b/docs/api/errors.rst @@ -1,7 +1,6 @@ -#################### -Errors API Reference -#################### +################ +Errors reference +################ .. automodule:: topgg.errors - :members: - :inherited-members: \ No newline at end of file + :members: \ No newline at end of file diff --git a/docs/api/types.rst b/docs/api/types.rst index a6a70f84..14b983db 100644 --- a/docs/api/types.rst +++ b/docs/api/types.rst @@ -1,6 +1,6 @@ -#################### -Models API Reference -#################### +################ +Models reference +################ .. automodule:: topgg.types :members: diff --git a/docs/api/webhook.rst b/docs/api/webhook.rst index 53a41c92..c1b067a5 100644 --- a/docs/api/webhook.rst +++ b/docs/api/webhook.rst @@ -1,6 +1,6 @@ -##################### -Webhook API Reference -##################### +################# +Webhook reference +################# .. automodule:: topgg.webhook :members: diff --git a/docs/conf.py b/docs/conf.py index ca87da68..f4f80040 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,23 +21,10 @@ import os import sys -import alabaster - sys.path.insert(0, os.path.abspath("../")) -from topgg import __version__ as version - -# import re +from topgg import __version__ as version -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. extensions = [ "sphinx.ext.autodoc", "sphinx.ext.viewcode", @@ -45,12 +32,13 @@ "sphinx.ext.extlinks", "sphinx.ext.intersphinx", "sphinx.ext.napoleon", + "sphinx_reredirects", ] autodoc_member_order = "groupwise" extlinks = { - "issue": ("https://github.com/top-gg-community/python-sdk/issues/%s", "GH-"), + "issue": ("https://github.com/top-gg-community/python-sdk/issues/%s", "#%s"), } intersphinx_mapping = { @@ -59,167 +47,38 @@ "aiohttp": ("https://docs.aiohttp.org/en/stable/", None), } -releases_github_path = "top-gg/python-sdk" - -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] +redirects = {"repository": "https://github.com/top-gg-community/python-sdk", "support": "https://discord.gg/dbl"} -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] +releases_github_path = "top-gg-community/python-sdk" source_suffix = ".rst" - -# The master toctree document. master_doc = "index" -# General information about the project. 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 -# built documents. -# -# The short X.Y version. - -# with open('../dbl/__init__.py') as f: -# version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE).group(1) -# The full version, including alpha/beta/rc tags. +copyright = "2021 Assanali Mukhanov; 2024 null8626" +author = "null8626" release = version -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# 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 +language = "en" exclude_patterns = ["_build"] -# -- Options for HTML output ---------------------------------------------- - -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" - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. +html_css_files = [ + "style.css", + "https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Roboto+Mono&display=swap", +] +html_favicon = "_static/favicon.ico" +html_theme = "furo" 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"] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -# html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -# html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -# html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -# html_additional_pages = {} - -# If false, no module index is generated. -# html_domain_indices = True - -# If false, no index is generated. -# html_use_index = True - -# If true, the index is split into individual pages for each letter. -# html_split_index = False - -# If true, links to the reST sources are added to the pages. -# html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. html_show_sphinx = False - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -# html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -# html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' -# html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# Now only 'ja' uses this config value -# html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -# html_search_scorer = 'scorer.js' - - -# -- Options for HTMLHelp output ------------------------------------------ - -# Output file base name for HTML help builder. htmlhelp_basename = "topggpydoc" -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). latex_documents = [ (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)] -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) texinfo_documents = [ ( master_doc, diff --git a/docs/index.rst b/docs/index.rst index a634d380..d8b9e3d0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,19 +3,53 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -################################### -Welcome to topggpy's documentation! -################################### +##################### +Top.gg Python Library +##################### + +.. image:: https://img.shields.io/pypi/v/topggpy.svg + :target: https://pypi.python.org/pypi/topggpy + :alt: View on PyPI +.. image:: https://img.shields.io/pypi/pyversions/topggpy.svg + :target: https://pypi.python.org/pypi/topggpy + :alt: v1.0.0 +.. image:: https://readthedocs.org/projects/topggpy/badge/?version=latest + :target: https://topggpy.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status + +A simple API wrapper for `Top.gg `_ written in Python. + +Installation +------------ + +.. code:: bash + + pip3 install topggpy + +Features +-------- + +* POST server count +* GET bot info, server count, upvote info +* GET user info +* GET widgets (large and small) including custom ones. See `docs.top.gg `_ for more info. +* GET weekend status +* Built-in webhook to handle Top.gg votes +* Automated server count posting +* Searching for bots via the API + +Additional information +---------------------- + +* Before using the webhook provided by this library, make sure that you have specified port open. +* Optimal values for port are between 1024 and 49151. +* If you happen to need help implementing topggpy in your bot, feel free to ask in the ``#development`` or ``#api`` channels in our `Discord server `_. .. toctree:: - :maxdepth: 1 + :maxdepth: 2 + :hidden: api whats_new - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` + repository + support \ No newline at end of file diff --git a/docs/repository.rst b/docs/repository.rst new file mode 100644 index 00000000..a542ad6a --- /dev/null +++ b/docs/repository.rst @@ -0,0 +1,5 @@ +================= +GitHub repository +================= + +You should be redirected in a few moments. Otherwise, click here: https://github.com/top-gg-community/python-sdk \ No newline at end of file diff --git a/docs/support.rst b/docs/support.rst new file mode 100644 index 00000000..531270f0 --- /dev/null +++ b/docs/support.rst @@ -0,0 +1,5 @@ +============== +Support server +============== + +You should be redirected in a few moments. Otherwise, click here: https://discord.gg/dbl \ No newline at end of file diff --git a/docs/topgg.svg b/docs/topgg.svg index 9afe2351..63f58125 100644 --- a/docs/topgg.svg +++ b/docs/topgg.svg @@ -1,10 +1,4 @@ - - - - - + + + diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 23e3ffe3..eaf52c67 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -3,22 +3,23 @@ .. _whats_new: ########## -What's New +What's new ########## This page keeps a detailed human friendly rendering of what's new and changed in specific versions. v2.0.1 ====== -* Added Python 3.12 support -* Dropped Python 3.6 and 3.7 support +* Added Python 3.12 support (:issue:`78`) +* Dropped Python 3.6 and 3.7 support (:issue:`75`) * Removed the need to manually set a ``default_bot_id`` property -* :attr:`BotData.def_avatar` is now an optional string -* :meth:`DBLClient.get_bots` is now deprecated -* :meth:`DBLClient.get_guild_count` no longer accepts a ``bot_id`` argument -* :meth:`DBLClient.get_bot_votes` no longer raises a ``ClientException`` without a ``default_bot_id`` property -* :meth:`DBLClient.get_bot_info` no longer raises a ``ClientException`` without a ``default_bot_id`` property -* :meth:`DBLClient.generate_widget`` no longer raises a ``ClientException`` without a ``default_bot_id`` property +* :attr:`~.BotData.def_avatar` is now an optional string +* :meth:`~.DBLClient.get_bots` is now deprecated +* :meth:`~.DBLClient.get_guild_count` no longer accepts a ``bot_id`` argument +* :meth:`~.DBLClient.get_bot_votes` no longer raises a :class:`~.ClientException` without a ``default_bot_id`` property +* :meth:`~.DBLClient.get_bot_info` no longer raises a :class:`~.ClientException` without a ``default_bot_id`` property +* :meth:`~.DBLClient.generate_widget` no longer raises a :class:`~.ClientException` without a ``default_bot_id`` property +* Documentation overhaul v2.0.0a ======= @@ -31,15 +32,15 @@ v2.0.0a v1.4.0 ====== -* The type of data passed to ``on_dbl_vote`` has been changed from :class:`dict` to :obj:`BotVoteData` -* The type of data passed to ``on_dsl_vote`` has been changed from :class:`dict` to :obj:`ServerVoteData` +* The type of data passed to ``on_dbl_vote`` has been changed from :class:`~.dict` to :obj:`BotVoteData` +* The type of data passed to ``on_dsl_vote`` has been changed from :class:`~.dict` to :obj:`ServerVoteData` v1.3.0 ====== * Introduced `global ratelimiter `__ to follow Top.gg global ratelimits - * Fixed an :exc:`AttributeError` raised by :meth:`HTTPClient.request` + * Fixed an :exc:`AttributeError` raised by :meth:`~.HTTPClient.request` * `Resource-specific ratelimit `__ is now actually resource-specific @@ -47,41 +48,41 @@ v1.2.0 ====== * Introduced global ratelimiter along with bot endpoints ratelimiter -* Follow consistency with typing in :class:`HTTPClient` and :class:`DBLClient` along with updated docstrings (:issue:`55`) +* Follow consistency with typing in :class:`~.HTTPClient` and :class:`~.DBLClient` along with updated docstrings (:issue:`55`) v1.1.0 ====== * Introduced `data models `__ - * :meth:`DBLClient.get_bot_votes` now returns a list of :class:`BriefUserData` objects + * :meth:`~.DBLClient.get_bot_votes` now returns a list of :class:`~.BriefUserData` objects - * :meth:`DBLClient.get_bot_info` now returns a :class:`BotData` object + * :meth:`~.DBLClient.get_bot_info` now returns a :class:`~.BotData` object - * :meth:`DBLClient.get_guild_count` now returns a :class:`BotStatsData` object + * :meth:`~.DBLClient.get_guild_count` now returns a :class:`~.BotStatsData` object - * :meth:`DBLClient.get_user_info` now returns a :class:`UserData` object + * :meth:`~.DBLClient.get_user_info` now returns a :class:`~.UserData` object -* :meth:`WebhookManager.run` now returns an :class:`asyncio.Task`, meaning it can now be optionally awaited +* :meth:`~.WebhookManager.run` now returns an :class:`~.asyncio.Task`, meaning it can now be optionally awaited v1.0.1 ====== -* :attr:`WebhookManager.webserver` now instead returns :class:`aiohttp.web.Application` for ease of use +* :attr:`~.WebhookManager.webserver` now instead returns :class:`~.aiohttp.web.Application` for ease of use v1.0.0 ====== * Renamed the module folder from ``dbl`` to ``topgg`` -* Added ``post_shard_count`` argument to :meth:`DBLClient.post_guild_count` +* Added ``post_shard_count`` argument to :meth:`~.DBLClient.post_guild_count` * Autopost now supports automatic shard posting (:issue:`42`) * Large webhook system rework, read the :obj:`api/webhook` section for more * Added support for server webhooks -* Renamed ``DBLException`` to :class:`TopGGException` -* Renamed ``DBLClient.get_bot_upvotes()`` to :meth:`DBLClient.get_bot_votes` -* Added :meth:`DBLClient.generate_widget` along with the ``widgets`` section in the documentation +* Renamed ``DBLException`` to :class:`~.TopGGException` +* Renamed ``DBLClient.get_bot_upvotes()`` to :meth:`~.DBLClient.get_bot_votes` +* Added :meth:`~.DBLClient.generate_widget` along with the ``widgets`` section in the documentation * Implemented a properly working ratelimiter * Added :func:`on_autopost_error` * All autopost events now follow ``on_autopost_x`` naming format, e.g. :func:`on_autopost_error`, :func:`on_autopost_success` @@ -90,7 +91,7 @@ v1.0.0 v0.4.0 ====== -* :meth:`DBLClient.post_guild_count` now supports a custom ``guild_count`` argument, which accepts either an integer or list of integers +* :meth:`~.DBLClient.post_guild_count` now supports a custom ``guild_count`` argument, which accepts either an integer or list of integers * Reworked how shard info is posted * Removed ``InvalidArgument`` and ``ConnectionClosed`` exceptions * Added ``ServerError`` exception @@ -99,12 +100,12 @@ v0.3.3 ====== * Internal changes regarding support of Top.gg migration -* Fixed errors raised when using :meth:`DBLClient.close` without built-in webhook +* Fixed errors raised when using :meth:`~.DBLClient.close` without built-in webhook v0.3.2 ====== -* ``Client`` class has been renamed to ``DBLClient`` +* ``Client`` class has been renamed to :class:`~.DBLClient` v0.3.1 ====== @@ -116,7 +117,7 @@ v0.3.1 v0.3.0 ====== -* :class:`DBLClient` now has ``autopost`` kwarg that will post server count automatically every 30 minutes +* :class:`~.DBLClient` now has ``autopost`` kwarg that will post server count automatically every 30 minutes * Fixed code 403 errors * Added ``on_dbl_vote``, an event that is called when you test your webhook * Added ``on_dbl_test``, an event that is called when someone tests your webhook @@ -126,7 +127,7 @@ v0.2.1 * Added webhook * Removed support for discord.py versions lower than 1.0.0 -* Made :meth:`DBLClient.get_weekend_status` return a boolean value +* Made :meth:`~.DBLClient.get_weekend_status` return a boolean value * Added webhook example in README * Removed ``post_server_count`` and ``get_server_count`` @@ -141,9 +142,9 @@ v0.2.0 * Made ``get_server_count`` an alias for ``get_guild_count`` -* Added :meth:`DBLClient.get_weekend_status` -* Removed all parameters from :meth:`DBLClient.get_upvote_info` -* Added limit to :meth:`DBLClient.get_bots` +* Added :meth:`~.DBLClient.get_weekend_status` +* Removed all parameters from :meth:`~.DBLClient.get_upvote_info` +* Added limit to :meth:`~.DBLClient.get_bots` * Fixed example in README v0.1.6 diff --git a/requirements-docs.txt b/requirements-docs.txt index b90afd89..8315d05b 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,4 +1,3 @@ +furo sphinx -insegel -sphinxcontrib-napoleon -sphinx-rtd-dark-mode \ No newline at end of file +sphinx-reredirects \ No newline at end of file diff --git a/topgg/autopost.py b/topgg/autopost.py index 96aa9888..7025b9b9 100644 --- a/topgg/autopost.py +++ b/topgg/autopost.py @@ -184,16 +184,14 @@ def stats(self, callback: t.Any = None) -> t.Any: # 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 = 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)) + # somewhere after the event loop has started autopost.start() """ @@ -222,11 +220,11 @@ def set_interval(self, seconds: t.Union[float, datetime.timedelta]) -> "AutoPost Sets the interval between posting stats. Args: - seconds (:obj:`typing.Union` [ :obj:`float`, :obj:`datetime.timedelta` ]) + seconds (Union[:obj:`float`, :obj:`datetime.timedelta`]) The interval. Raises: - :obj:`ValueError` + ValueError If the provided interval is less than 900 seconds. """ if isinstance(seconds, datetime.timedelta): @@ -283,7 +281,7 @@ def start(self) -> "asyncio.Task[None]": This method must be called when the event loop has already running! Raises: - :obj:`~.errors.TopGGException` + :exc:`~.errors.TopGGException` If there's no callback provided or the autopost is already running. """ if not hasattr(self, "_stats"): diff --git a/topgg/client.py b/topgg/client.py index cab34637..1be4edda 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -100,7 +100,7 @@ async def get_weekend_status(self) -> bool: :obj:`bool`: The boolean value of weekend status. Raises: - :obj:`~.errors.ClientStateException` + :exc:`~.errors.ClientStateException` If the client has been closed. """ await self._ensure_session() @@ -139,18 +139,18 @@ async def post_guild_count( An instance of StatsWrapper containing guild_count, shard_count, and shard_id. Keyword Arguments: - guild_count (:obj:`typing.Optional` [:obj:`typing.Union` [ :obj:`int`, :obj:`list` [ :obj:`int` ]]]) + guild_count (Optional[Union[:obj:`int`, 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. - shard_count (:obj:`.typing.Optional` [ :obj:`int` ]) + shard_count (Optional[:obj:`int`]) The total number of shards. - shard_id (:obj:`.typing.Optional` [ :obj:`int` ]) + shard_id (Optional[:obj:`int`]) The index of the current shard. Top.gg uses `0 based indexing`_ for shards. Raises: TypeError If no argument is provided. - :obj:`~.errors.ClientStateException` + :exc:`~.errors.ClientStateException` If the client has been closed. """ if stats: @@ -170,7 +170,7 @@ async def get_guild_count(self) -> types.BotStatsData: The guild count and shards of a bot on Top.gg. Raises: - :obj:`~.errors.ClientStateException` + :exc:`~.errors.ClientStateException` If the client has been closed. """ await self._ensure_session() @@ -184,11 +184,11 @@ async def get_bot_votes(self) -> t.List[types.BriefUserData]: This API endpoint is only available to the bot's owner. Returns: - :obj:`list` [ :obj:`~.types.BriefUserData` ]: + List[:obj:`~.types.BriefUserData` ]: Users who voted for your bot. Raises: - :obj:`~.errors.ClientStateException` + :exc:`~.errors.ClientStateException` If the client has been closed. """ await self._ensure_session() @@ -210,7 +210,7 @@ async def get_bot_info(self, bot_id: t.Optional[int] = None) -> types.BotData: `here `_. Raises: - :obj:`~.errors.ClientStateException` + :exc:`~.errors.ClientStateException` If the client has been closed. """ await self._ensure_session() @@ -251,7 +251,7 @@ async def get_user_info(self, user_id: int) -> types.UserData: Information about a Top.gg user. Raises: - :obj:`~.errors.ClientStateException` + :exc:`~.errors.ClientStateException` If the client has been closed. """ await self._ensure_session() @@ -269,7 +269,7 @@ async def get_user_vote(self, user_id: int) -> bool: :obj:`bool`: Info about the user's vote. Raises: - :obj:`~.errors.ClientStateException` + :exc:`~.errors.ClientStateException` If the client has been closed. """ await self._ensure_session() diff --git a/topgg/data.py b/topgg/data.py index baafde66..7d5f422d 100644 --- a/topgg/data.py +++ b/topgg/data.py @@ -36,7 +36,7 @@ def data(type_: t.Type[T]) -> T: Represents the injected data. This should be set as the parameter's default value. Args: - `type_` (:obj:`type` [ :obj:`T` ]) + `type_` (:obj:`type` [ :obj:`T`]) The type of the injected data. Returns: @@ -85,13 +85,13 @@ def set_data(self: DataContainerT, data_: t.Any, *, override: bool = False) -> D Sets data to be available in your functions. Args: - `data_` (:obj:`typing.Any`) + `data_` (Any) The data to be injected. override (:obj:`bool`) Whether or not to override another instance that already exists. Raises: - :obj:`~.errors.TopGGException` + :exc:`~.errors.TopGGException` If override is False and another instance of the same type exists. """ type_ = type(data_) diff --git a/topgg/webhook.py b/topgg/webhook.py index 28b92e91..01b30f7b 100644 --- a/topgg/webhook.py +++ b/topgg/webhook.py @@ -83,16 +83,16 @@ def endpoint(self, endpoint_: t.Optional["WebhookEndpoint"] = None) -> t.Any: """Helper method that returns a WebhookEndpoint object. Args: - `endpoint_` (:obj:`typing.Optional` [ :obj:`WebhookEndpoint` ]) + `endpoint_` (Optional[:obj:`WebhookEndpoint`]) The endpoint to add. Returns: - :obj:`typing.Union` [ :obj:`WebhookManager`, :obj:`BoundWebhookEndpoint` ]: + Union[:obj:`WebhookManager`, :obj:`BoundWebhookEndpoint` ]: An instance of :obj:`WebhookManager` if endpoint was provided, otherwise :obj:`BoundWebhookEndpoint`. Raises: - :obj:`~.errors.TopGGException` + :exc:`~.errors.TopGGException` If the endpoint is lacking attributes. """ if endpoint_: @@ -328,7 +328,7 @@ def endpoint(route: str, type: WebhookType, auth: str = "") -> t.Callable[[t.Cal The auth for the endpoint. Returns: - :obj:`typing.Callable` [[ :obj:`typing.Callable` [..., :obj:`typing.Any` ]], :obj:`WebhookEndpoint` ]: + Callable[[Callable[..., Any]], :obj:`WebhookEndpoint`]: The actual decorator. :Example: From 99b9bc305a42848223971a2f941bdf1c85e64175 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 28 Mar 2024 17:13:18 +0700 Subject: [PATCH 023/126] doc: add examples --- docs/_static/script.js | 22 ++++++++++++++++++++++ docs/api.rst | 6 ------ docs/conf.py | 8 +++++++- docs/examples.rst | 9 +++++++++ docs/examples/discord_py.rst | 5 +++++ docs/examples/hikari.rst | 5 +++++ docs/index.rst | 1 + docs/whats_new.rst | 4 ---- examples/discordpy_example/__main__.py | 1 + examples/hikari_example/__main__.py | 1 + 10 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 docs/_static/script.js create mode 100644 docs/examples.rst create mode 100644 docs/examples/discord_py.rst create mode 100644 docs/examples/hikari.rst diff --git a/docs/_static/script.js b/docs/_static/script.js new file mode 100644 index 00000000..0d7b93f8 --- /dev/null +++ b/docs/_static/script.js @@ -0,0 +1,22 @@ +document.addEventListener('load', () => { + try { + document.querySelector('.edit-this-page').remove() + + // remove these useless crap that appears on official readthedocs builds + document.querySelector('#furo-readthedocs-versions').remove() + document.querySelector('.injected').remove() + } catch { + // we're building this locally, forget it + } +}) + +const findChildrenWithName = (elem, name) => [...elem.children].find(child => child.nodeName === name) + +for (const label of document.querySelectorAll('.sidebar-container label')) { + const link = findChildrenWithName(label.parentElement, 'A') + + link.addEventListener('click', event => { + event.preventDefault() + label.click() + }) +} \ No newline at end of file diff --git a/docs/api.rst b/docs/api.rst index c1dc98ff..28409ff7 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,13 +1,7 @@ -.. currentmodule:: topgg - ############# API reference ############# -The following section outlines the API of topggpy. - -Index: - .. toctree:: :maxdepth: 2 diff --git a/docs/conf.py b/docs/conf.py index f4f80040..492b540a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -47,7 +47,12 @@ "aiohttp": ("https://docs.aiohttp.org/en/stable/", None), } -redirects = {"repository": "https://github.com/top-gg-community/python-sdk", "support": "https://discord.gg/dbl"} +redirects = { + "repository": "https://github.com/top-gg-community/python-sdk", + "support": "https://discord.gg/dbl", + "examples/discord_py": "https://github.com/Top-gg-Community/python-sdk/tree/master/examples/discordpy_example", + "examples/hikari": "https://github.com/Top-gg-Community/python-sdk/tree/master/examples/hikari_example", +} releases_github_path = "top-gg-community/python-sdk" source_suffix = ".rst" @@ -61,6 +66,7 @@ language = "en" exclude_patterns = ["_build"] +html_js_files = ["script.js"] html_css_files = [ "style.css", "https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Roboto+Mono&display=swap", diff --git a/docs/examples.rst b/docs/examples.rst new file mode 100644 index 00000000..c18a83f6 --- /dev/null +++ b/docs/examples.rst @@ -0,0 +1,9 @@ +######## +Examples +######## + + .. toctree:: + :maxdepth: 2 + + examples/discord_py + examples/hikari \ No newline at end of file diff --git a/docs/examples/discord_py.rst b/docs/examples/discord_py.rst new file mode 100644 index 00000000..e77808d8 --- /dev/null +++ b/docs/examples/discord_py.rst @@ -0,0 +1,5 @@ +================== +Discord.py example +================== + +You should be redirected in a few moments. Otherwise, click here: https://github.com/Top-gg-Community/python-sdk/tree/master/examples/discordpy_example \ No newline at end of file diff --git a/docs/examples/hikari.rst b/docs/examples/hikari.rst new file mode 100644 index 00000000..89fc6513 --- /dev/null +++ b/docs/examples/hikari.rst @@ -0,0 +1,5 @@ +============== +Hikari example +============== + +You should be redirected in a few moments. Otherwise, click here: https://github.com/Top-gg-Community/python-sdk/tree/master/examples/hikari_example \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index d8b9e3d0..f616a71c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -50,6 +50,7 @@ Additional information :hidden: api + examples whats_new repository support \ No newline at end of file diff --git a/docs/whats_new.rst b/docs/whats_new.rst index eaf52c67..d506500b 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -1,7 +1,3 @@ -.. currentmodule:: topgg - -.. _whats_new: - ########## What's new ########## diff --git a/examples/discordpy_example/__main__.py b/examples/discordpy_example/__main__.py index 67e58232..70771708 100644 --- a/examples/discordpy_example/__main__.py +++ b/examples/discordpy_example/__main__.py @@ -1,6 +1,7 @@ # The MIT License (MIT) # Copyright (c) 2021 Norizon +# Copyright (c) 2024 null8626 # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), diff --git a/examples/hikari_example/__main__.py b/examples/hikari_example/__main__.py index 857329c8..82da8ba0 100644 --- a/examples/hikari_example/__main__.py +++ b/examples/hikari_example/__main__.py @@ -1,6 +1,7 @@ # The MIT License (MIT) # Copyright (c) 2021 Norizon +# Copyright (c) 2024 null8626 # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), From 8ea8f7adfe8a01e474e455918d9fca140274b077 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 28 Mar 2024 17:26:38 +0700 Subject: [PATCH 024/126] doc: more redirects and refactor js script --- docs/_static/script.js | 4 +--- docs/api.rst | 13 ------------- docs/api/index.rst | 13 +++++++++++++ docs/conf.py | 2 ++ docs/{examples.rst => examples/index.rst} | 4 ++-- docs/index.rst | 7 ++----- 6 files changed, 20 insertions(+), 23 deletions(-) delete mode 100644 docs/api.rst create mode 100644 docs/api/index.rst rename docs/{examples.rst => examples/index.rst} (58%) diff --git a/docs/_static/script.js b/docs/_static/script.js index 0d7b93f8..c8cfd5b7 100644 --- a/docs/_static/script.js +++ b/docs/_static/script.js @@ -10,10 +10,8 @@ document.addEventListener('load', () => { } }) -const findChildrenWithName = (elem, name) => [...elem.children].find(child => child.nodeName === name) - for (const label of document.querySelectorAll('.sidebar-container label')) { - const link = findChildrenWithName(label.parentElement, 'A') + const link = [...label.parentElement.children].find(child => child.nodeName === 'A') link.addEventListener('click', event => { event.preventDefault() diff --git a/docs/api.rst b/docs/api.rst deleted file mode 100644 index 28409ff7..00000000 --- a/docs/api.rst +++ /dev/null @@ -1,13 +0,0 @@ -############# -API reference -############# - - .. toctree:: - :maxdepth: 2 - - api/autopost - api/client - api/data - api/errors - api/types - api/webhook \ No newline at end of file diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 00000000..4c4e05bd --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,13 @@ +############# +API reference +############# + + .. toctree:: + :maxdepth: 2 + + autopost + client + data + errors + types + webhook \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 492b540a..50eaf9d5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,6 +50,8 @@ redirects = { "repository": "https://github.com/top-gg-community/python-sdk", "support": "https://discord.gg/dbl", + "api/index": "autopost.html", + "examples/index": "discord_py.html", "examples/discord_py": "https://github.com/Top-gg-Community/python-sdk/tree/master/examples/discordpy_example", "examples/hikari": "https://github.com/Top-gg-Community/python-sdk/tree/master/examples/hikari_example", } diff --git a/docs/examples.rst b/docs/examples/index.rst similarity index 58% rename from docs/examples.rst rename to docs/examples/index.rst index c18a83f6..20573261 100644 --- a/docs/examples.rst +++ b/docs/examples/index.rst @@ -5,5 +5,5 @@ Examples .. toctree:: :maxdepth: 2 - examples/discord_py - examples/hikari \ No newline at end of file + discord_py + hikari \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index f616a71c..2f9bd0ea 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,9 +10,6 @@ Top.gg Python Library .. image:: https://img.shields.io/pypi/v/topggpy.svg :target: https://pypi.python.org/pypi/topggpy :alt: View on PyPI -.. image:: https://img.shields.io/pypi/pyversions/topggpy.svg - :target: https://pypi.python.org/pypi/topggpy - :alt: v1.0.0 .. image:: https://readthedocs.org/projects/topggpy/badge/?version=latest :target: https://topggpy.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status @@ -49,8 +46,8 @@ Additional information :maxdepth: 2 :hidden: - api - examples + api/index.rst + examples/index.rst whats_new repository support \ No newline at end of file From 1ed5505e7f6bde3fe6c626466a961c41e67dba81 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 28 Mar 2024 17:48:20 +0700 Subject: [PATCH 025/126] doc: tweaks --- topgg/client.py | 7 +++++-- topgg/http.py | 5 ++++- topgg/webhook.py | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index 1be4edda..b895806e 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -184,7 +184,7 @@ async def get_bot_votes(self) -> t.List[types.BriefUserData]: This API endpoint is only available to the bot's owner. Returns: - List[:obj:`~.types.BriefUserData` ]: + List[:obj:`~.types.BriefUserData`]: Users who voted for your bot. Raises: @@ -225,7 +225,10 @@ async def get_bots( search: t.Optional[t.Dict[str, t.Any]] = None, fields: t.Optional[t.List[str]] = None, ) -> types.DataDict[str, t.Any]: - """This function is deprecated.""" + """ + Warning: + This function is deprecated. + """ warnings.warn("get_bots is now deprecated.", DeprecationWarning) diff --git a/topgg/http.py b/topgg/http.py index 00717666..4333c4a2 100644 --- a/topgg/http.py +++ b/topgg/http.py @@ -202,7 +202,10 @@ def get_bots( search: Dict[str, str], fields: Sequence[str], ) -> Coroutine[Any, Any, dict]: - """This function is now deprecated.""" + """ + Warning: + This function is deprecated. + """ warnings.warn("get_bots is now deprecated.", DeprecationWarning) diff --git a/topgg/webhook.py b/topgg/webhook.py index 01b30f7b..9a3fd7f8 100644 --- a/topgg/webhook.py +++ b/topgg/webhook.py @@ -87,7 +87,7 @@ def endpoint(self, endpoint_: t.Optional["WebhookEndpoint"] = None) -> t.Any: The endpoint to add. Returns: - Union[:obj:`WebhookManager`, :obj:`BoundWebhookEndpoint` ]: + Union[:obj:`WebhookManager`, :obj:`BoundWebhookEndpoint`]: An instance of :obj:`WebhookManager` if endpoint was provided, otherwise :obj:`BoundWebhookEndpoint`. From ad4386b3affffa3b6ed06af32d2b429435106d0a Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 28 Mar 2024 17:52:03 +0700 Subject: [PATCH 026/126] doc: show monthly downloads --- docs/index.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 2f9bd0ea..82acd4af 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,9 +10,9 @@ Top.gg Python Library .. image:: https://img.shields.io/pypi/v/topggpy.svg :target: https://pypi.python.org/pypi/topggpy :alt: View on PyPI -.. image:: https://readthedocs.org/projects/topggpy/badge/?version=latest +.. image:: https://img.shields.io/pypi/dm/topggpy?style=flat-square :target: https://topggpy.readthedocs.io/en/latest/?badge=latest - :alt: Documentation Status + :alt: Monthly PyPI downloads A simple API wrapper for `Top.gg `_ written in Python. From dd1a277a9399e236db15a0d6b5d11a9a1e19d1cb Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 28 Mar 2024 17:54:23 +0700 Subject: [PATCH 027/126] doc: show up monthly pypi downloads --- README.rst | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 99fa29e5..b29327f0 100644 --- a/README.rst +++ b/README.rst @@ -5,12 +5,9 @@ Top.gg Python Library .. image:: https://img.shields.io/pypi/v/topggpy.svg :target: https://pypi.python.org/pypi/topggpy :alt: View on PyPI -.. image:: https://img.shields.io/pypi/pyversions/topggpy.svg - :target: https://pypi.python.org/pypi/topggpy - :alt: v1.0.0 -.. image:: https://readthedocs.org/projects/topggpy/badge/?version=latest +.. image:: https://img.shields.io/pypi/dm/topggpy?style=flat-square :target: https://topggpy.readthedocs.io/en/latest/?badge=latest - :alt: Documentation Status + :alt: Monthly PyPI downloads A simple API wrapper for `Top.gg `_ written in Python. From cd6380f72ccdd3d59657faed79c320c69b85ed72 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 28 Mar 2024 17:59:02 +0700 Subject: [PATCH 028/126] doc: use pip, not pip3 --- README.rst | 2 +- docs/index.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index b29327f0..423eae6a 100644 --- a/README.rst +++ b/README.rst @@ -16,7 +16,7 @@ Installation .. code:: bash - pip3 install topggpy + pip install topggpy Documentation ------------- diff --git a/docs/index.rst b/docs/index.rst index 82acd4af..f2aebe23 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,7 +21,7 @@ Installation .. code:: bash - pip3 install topggpy + pip install topggpy Features -------- From 023d63e0af639078aa2f2640586f4a7c68714319 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 28 Mar 2024 21:16:10 +0700 Subject: [PATCH 029/126] meta: update project URLs --- README.rst | 2 +- pyproject.toml | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 423eae6a..928930b5 100644 --- a/README.rst +++ b/README.rst @@ -21,7 +21,7 @@ Installation Documentation ------------- -Documentation can be found `here `_ +Documentation can be found `here `_ Features -------- diff --git a/pyproject.toml b/pyproject.toml index fc1cdce2..5c2dc6f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ classifiers = [ requires-python = ">=3.8" [project.urls] -Homepage = "https://topggpy.readthedocs.io/en/stable/" -Documentation = "https://topggpy.readthedocs.io/en/stable/" -Repository = "https://github.com/top-gg-community/python-sdk" \ No newline at end of file +Documentation = "https://topggpy.readthedocs.io/en/latest/" +"Release notes" = "https://topggpy.readthedocs.io/en/latest/whats_new.html" +Repository = "https://github.com/top-gg-community/python-sdk" +"Support server" = "https://discord.gg/dbl" \ No newline at end of file From f80bd29fb4bc6430d3cddb0e2497f4e0a17112ed Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 6 Jun 2024 10:30:30 +0700 Subject: [PATCH 030/126] fix: shutdown web.Application on close() --- topgg/webhook.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/topgg/webhook.py b/topgg/webhook.py index 9a3fd7f8..a491fb60 100644 --- a/topgg/webhook.py +++ b/topgg/webhook.py @@ -3,6 +3,7 @@ # The MIT License (MIT) # Copyright (c) 2021 Assanali Mukhanov +# Copyright (c) 2024 null8626 # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), @@ -70,7 +71,6 @@ class WebhookManager(DataContainerMixin): def __init__(self) -> None: super().__init__() - self.__app = web.Application() self._is_running = False @t.overload @@ -120,6 +120,8 @@ async def start(self, port: int) -> None: port (int) The port to run the webhook on. """ + + self.__app = web.Application() runner = web.AppRunner(self.__app) await runner.setup() self._webserver = web.TCPSite(runner, "0.0.0.0", port) @@ -144,6 +146,7 @@ def app(self) -> web.Application: async def close(self) -> None: """Stops the webhook.""" await self._webserver.stop() + await self.__app.shutdown() self._is_running = False def _get_handler(self, type_: WebhookType, auth: str, callback: t.Callable[..., t.Any]) -> _HandlerT: From dcfdaea0f04a252461dbafa88ea17cb01e241805 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 6 Jun 2024 13:29:09 +0700 Subject: [PATCH 031/126] fix: initialize __app as None at initialization --- topgg/webhook.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/topgg/webhook.py b/topgg/webhook.py index a491fb60..b3c961b4 100644 --- a/topgg/webhook.py +++ b/topgg/webhook.py @@ -71,6 +71,8 @@ class WebhookManager(DataContainerMixin): def __init__(self) -> None: super().__init__() + + self.__app = None self._is_running = False @t.overload @@ -145,6 +147,7 @@ def app(self) -> web.Application: async def close(self) -> None: """Stops the webhook.""" + await self._webserver.stop() await self.__app.shutdown() self._is_running = False From 549eb593d47f516bc0c131300d93aecbeb1611e1 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 29 Jun 2024 18:07:20 +0700 Subject: [PATCH 032/126] meta: bump aiohttp --- pyproject.toml | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5c2dc6f0..80a0f4d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.rst" license = { text = "MIT" } authors = [{ name = "null8626" }, { name = "Top.gg" }] keywords = ["discord", "bot", "topgg", "top.gg"] -dependencies = ["aiohttp>=3.9.0"] +dependencies = ["aiohttp>=3.9.5"] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", diff --git a/requirements.txt b/requirements.txt index 1b80c609..86edf4d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -aiohttp>=3.9.0 \ No newline at end of file +aiohttp>=3.9.5 \ No newline at end of file From 47e96f51766b51c2504b7c440338b5f718c8675b Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 1 Jul 2024 21:41:26 +0700 Subject: [PATCH 033/126] refactor: remove unneeded non-base64 removal --- topgg/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index b895806e..50e0fc47 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -27,7 +27,6 @@ import base64 import json -import re import typing as t import warnings @@ -70,7 +69,7 @@ def __init__( self._token = token try: - encoded_json = re.sub(r"[^a-zA-Z0-9\+\/]+", "", token.split(".")[1]) + encoded_json = token.split(".")[1] encoded_json += "=" * (4 - (len(encoded_json) % 4)) self.bot_id = int(json.loads(base64.b64decode(encoded_json))["id"]) From 1d3f36d4feb24be685839d941553b86bcaf17c44 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 24 Sep 2024 08:51:30 +0700 Subject: [PATCH 034/126] deps: manually bump aiohttp version to at least version 3.10.5 --- pyproject.toml | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 80a0f4d2..cebb7cbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.rst" license = { text = "MIT" } authors = [{ name = "null8626" }, { name = "Top.gg" }] keywords = ["discord", "bot", "topgg", "top.gg"] -dependencies = ["aiohttp>=3.9.5"] +dependencies = ["aiohttp>=3.10.5"] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", diff --git a/requirements.txt b/requirements.txt index 86edf4d0..225877d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -aiohttp>=3.9.5 \ No newline at end of file +aiohttp>=3.10.5 \ No newline at end of file From 404d7134c131e493eb3efd5cc2da37659c10d296 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 24 Sep 2024 08:52:34 +0700 Subject: [PATCH 035/126] meta: remove unneeded scripts directory as we already have ruff --- MANIFEST.in | 1 - scripts/format.sh | 2 -- 2 files changed, 3 deletions(-) delete mode 100644 scripts/format.sh diff --git a/MANIFEST.in b/MANIFEST.in index 5dec6b10..e1c7d412 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,7 +2,6 @@ prune .github prune .ruff_cache prune docs prune examples -prune scripts prune tests exclude .gitattributes exclude .gitignore 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 From 59522092df06092c8fe11cf5a9456bc414136115 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 24 Sep 2024 08:56:37 +0700 Subject: [PATCH 036/126] meta: add .ruff_cache to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 51f781c6..52fd1028 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ docs/_templates .idea/ **/__pycache__/ .coverage +.ruff_cache/ \ No newline at end of file From 9f092a39e939b7d7cfa1a713519a09d34c4a2543 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 28 Sep 2024 17:28:04 +0700 Subject: [PATCH 037/126] meta: update keywords --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cebb7cbc..1b4a8f05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ description = "A simple API wrapper for Top.gg written in Python." readme = "README.rst" license = { text = "MIT" } authors = [{ name = "null8626" }, { name = "Top.gg" }] -keywords = ["discord", "bot", "topgg", "top.gg"] +keywords = ["discord", "discord-bot", "topgg"] dependencies = ["aiohttp>=3.10.5"] classifiers = [ "Development Status :: 5 - Production/Stable", From 44cc56c6fdeb847a03715a567805cba4c3c44304 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 28 Sep 2024 17:30:01 +0700 Subject: [PATCH 038/126] deps: bump aiohttp dependency version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1b4a8f05..112daa49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.rst" license = { text = "MIT" } authors = [{ name = "null8626" }, { name = "Top.gg" }] keywords = ["discord", "discord-bot", "topgg"] -dependencies = ["aiohttp>=3.10.5"] +dependencies = ["aiohttp>=3.10.7"] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", From 06966d5dd6bed8820404d1fbd28e0ed1c991298b Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Sat, 28 Sep 2024 17:31:17 +0700 Subject: [PATCH 039/126] deps: bump aiohttp version again --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 225877d9..04c8fa4c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -aiohttp>=3.10.5 \ No newline at end of file +aiohttp>=3.10.7 From 0cecb50137e9711fd673e377f158a7c7c12959be Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 28 Sep 2024 19:57:08 +0700 Subject: [PATCH 040/126] meta: remove obselete comments and update __all__ to use tuples [skip ci] --- topgg/__init__.py | 2 -- topgg/autopost.py | 2 +- topgg/client.py | 4 +--- topgg/data.py | 2 +- topgg/errors.py | 6 ++---- topgg/http.py | 4 +--- topgg/ratelimiter.py | 2 -- topgg/types.py | 4 +--- topgg/webhook.py | 6 ++---- 9 files changed, 9 insertions(+), 23 deletions(-) diff --git a/topgg/__init__.py b/topgg/__init__.py index 3c5f8451..5262f281 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ Top.gg Python API Wrapper ~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/topgg/autopost.py b/topgg/autopost.py index 7025b9b9..c2ffb3a3 100644 --- a/topgg/autopost.py +++ b/topgg/autopost.py @@ -20,7 +20,7 @@ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. -__all__ = ["AutoPoster"] +__all__ = ("AutoPoster",) import asyncio import datetime diff --git a/topgg/client.py b/topgg/client.py index 50e0fc47..919e6ed9 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # The MIT License (MIT) # Copyright (c) 2021 Assanali Mukhanov @@ -23,7 +21,7 @@ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. -__all__ = ["DBLClient"] +__all__ = ("DBLClient",) import base64 import json diff --git a/topgg/data.py b/topgg/data.py index 7d5f422d..abceaa6c 100644 --- a/topgg/data.py +++ b/topgg/data.py @@ -20,7 +20,7 @@ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. -__all__ = ["data", "DataContainerMixin"] +__all__ = ("data", "DataContainerMixin",) import inspect import typing as t diff --git a/topgg/errors.py b/topgg/errors.py index d8c157a5..914e236a 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # The MIT License (MIT) # Copyright (c) 2021 Assanali Mukhanov @@ -22,7 +20,7 @@ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. -__all__ = [ +__all__ = ( "TopGGException", "ClientException", "ClientStateException", @@ -32,7 +30,7 @@ "Forbidden", "NotFound", "ServerError", -] +) from typing import TYPE_CHECKING, Union diff --git a/topgg/http.py b/topgg/http.py index 4333c4a2..d52dc8da 100644 --- a/topgg/http.py +++ b/topgg/http.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # The MIT License (MIT) # Copyright (c) 2021 Assanali Mukhanov @@ -23,7 +21,7 @@ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. -__all__ = ["HTTPClient"] +__all__ = ("HTTPClient",) import asyncio import json diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index d2d75f79..77b7a087 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # The MIT License (MIT) # Copyright (c) 2021 Assanali Mukhanov diff --git a/topgg/types.py b/topgg/types.py index 8bfd0671..84d91911 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # The MIT License (MIT) # Copyright (c) 2021 Assanali Mukhanov @@ -22,7 +20,7 @@ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. -__all__ = ["WidgetOptions", "StatsWrapper"] +__all__ = ("WidgetOptions", "StatsWrapper") import dataclasses import typing as t diff --git a/topgg/webhook.py b/topgg/webhook.py index b3c961b4..1e95fba4 100644 --- a/topgg/webhook.py +++ b/topgg/webhook.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # The MIT License (MIT) # Copyright (c) 2021 Assanali Mukhanov @@ -23,13 +21,13 @@ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. -__all__ = [ +__all__ = ( "endpoint", "BoundWebhookEndpoint", "WebhookEndpoint", "WebhookManager", "WebhookType", -] +) import enum import typing as t From 5fa5aaa61fe365b3c531f2e1903bdbe9ddf33d87 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 28 Sep 2024 20:14:12 +0700 Subject: [PATCH 041/126] refactor: add __slots__ --- topgg/autopost.py | 2 +- topgg/client.py | 3 ++- topgg/data.py | 9 ++++++--- topgg/http.py | 18 +++++++++++++++--- topgg/ratelimiter.py | 6 +++++- topgg/types.py | 18 ++++++++++++++++++ topgg/webhook.py | 11 +++++++---- 7 files changed, 54 insertions(+), 13 deletions(-) diff --git a/topgg/autopost.py b/topgg/autopost.py index c2ffb3a3..aae19eaa 100644 --- a/topgg/autopost.py +++ b/topgg/autopost.py @@ -54,7 +54,7 @@ class AutoPoster: An instance of DBLClient. """ - __slots__ = ( + __slots__: t.Tuple[str, ...] = ( "_error", "_success", "_interval", diff --git a/topgg/client.py b/topgg/client.py index 919e6ed9..9622018b 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -53,7 +53,8 @@ class DBLClient(DataContainerMixin): Arbitrary kwargs to be passed to :class:`aiohttp.ClientSession` if session was not provided. """ - __slots__ = ("http", "bot_id", "_token", "_is_closed", "_autopost") + __slots__: t.Tuple[str, ...] = ("http", "bot_id", "_token", "_is_closed", "_autopost") + http: HTTPClient def __init__( diff --git a/topgg/data.py b/topgg/data.py index abceaa6c..639c8d21 100644 --- a/topgg/data.py +++ b/topgg/data.py @@ -20,7 +20,10 @@ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. -__all__ = ("data", "DataContainerMixin",) +__all__ = ( + "data", + "DataContainerMixin", +) import inspect import typing as t @@ -61,7 +64,7 @@ def get_stats(client: Client = topgg.data(Client)): class Data(t.Generic[T]): - __slots__ = ("type",) + __slots__: t.Tuple[str, ...] = ("type",) def __init__(self, type_: t.Type[T]) -> None: self.type: t.Type[T] = type_ @@ -75,7 +78,7 @@ class DataContainerMixin: as arguments in your functions. """ - __slots__ = ("_data",) + __slots__: t.Tuple[str, ...] = ("_data",) def __init__(self) -> None: self._data: t.Dict[t.Type, t.Any] = {type(self): self} diff --git a/topgg/http.py b/topgg/http.py index d52dc8da..7c09d5dd 100644 --- a/topgg/http.py +++ b/topgg/http.py @@ -29,7 +29,7 @@ import sys import warnings from datetime import datetime -from typing import Any, Coroutine, Dict, Iterable, List, Optional, Sequence, Union, cast +from typing import Any, Coroutine, Dict, Iterable, List, Optional, Sequence, Union, cast, Tuple import aiohttp from aiohttp import ClientResponse @@ -49,6 +49,9 @@ async def _json_or_text( return text +BASE = "https://top.gg/api" + + class HTTPClient: """Represents an HTTP client sending HTTP requests to the Top.gg API. @@ -65,6 +68,16 @@ class HTTPClient: Arbitrary kwargs to be passed to :class:`aiohttp.ClientSession`. """ + __slots__: Tuple[str, ...] = ( + "token", + "_own_session", + "session", + "global_rate_limiter", + "bot_rate_limiter", + "rate_limiters", + "user_agent", + ) + def __init__( self, token: str, @@ -72,7 +85,6 @@ def __init__( session: Optional[aiohttp.ClientSession] = None, **kwargs: Any, ) -> None: - self.BASE = "https://top.gg/api" self.token = token self._own_session = session is None self.session: aiohttp.ClientSession = session or aiohttp.ClientSession(**kwargs) @@ -87,7 +99,7 @@ def __init__( async def request(self, method: str, endpoint: str, **kwargs: Any) -> dict: """Handles requests to the API.""" rate_limiters = self.rate_limiters if endpoint.startswith("/bots") else self.global_rate_limiter - url = f"{self.BASE}{endpoint}" + url = BASE + endpoint if not self.token: raise errors.UnauthorizedDetected("Top.gg API token not provided") diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index 77b7a087..b95e5e92 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -24,7 +24,7 @@ import collections from datetime import datetime from types import TracebackType -from typing import Any, Awaitable, Callable, List, Optional, Type +from typing import Any, Awaitable, Callable, List, Optional, Type, Tuple class AsyncRateLimiter: @@ -32,6 +32,8 @@ class AsyncRateLimiter: Provides rate limiting for an operation with a configurable number of requests for a time period. """ + __slots__: Tuple[str, ...] = ("__lock", "callback", "max_calls", "period", "calls") + __lock: asyncio.Lock callback: Optional[Callable[[float], Awaitable[Any]]] max_calls: int @@ -85,6 +87,8 @@ def _timespan(self) -> float: class AsyncRateLimiterManager: + __slots__: Tuple[str, ...] = ("rate_limiters",) + rate_limiters: List[AsyncRateLimiter] def __init__(self, rate_limiters: List[AsyncRateLimiter]): diff --git a/topgg/types.py b/topgg/types.py index 84d91911..746a1099 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -140,6 +140,8 @@ 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`.""" + __slots__: t.Tuple[str, ...] = () + 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 @@ -200,6 +202,8 @@ 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 `__.""" + __slots__: t.Tuple[str, ...] = () + id: int """The ID of the bot.""" @@ -270,6 +274,8 @@ def __init__(self, **kwargs: t.Any): class BotStatsData(DataDict[str, t.Any]): """Model that contains information about a listed bot's guild and shard count.""" + __slots__: t.Tuple[str, ...] = () + server_count: t.Optional[int] """The amount of servers the bot is in.""" shards: t.List[int] @@ -284,6 +290,8 @@ def __init__(self, **kwargs: t.Any): class BriefUserData(DataDict[str, t.Any]): """Model that contains brief information about a Top.gg user.""" + __slots__: t.Tuple[str, ...] = () + id: int """The Discord ID of the user.""" username: str @@ -300,6 +308,8 @@ def __init__(self, **kwargs: t.Any): class SocialData(DataDict[str, str]): """Model that contains social information about a top.gg user.""" + __slots__: t.Tuple[str, ...] = () + youtube: str """The YouTube channel ID of the user.""" reddit: str @@ -316,6 +326,8 @@ 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__: t.Tuple[str, ...] = () + id: int """The ID of the user.""" @@ -353,6 +365,8 @@ def __init__(self, **kwargs: t.Any): class VoteDataDict(DataDict[str, t.Any]): """Base model that represents received information from Top.gg via webhooks.""" + __slots__: t.Tuple[str, ...] = () + type: str """Type of the action (``upvote`` or ``test``).""" user: int @@ -367,6 +381,8 @@ def __init__(self, **kwargs: t.Any): class BotVoteData(VoteDataDict): """Model that contains information about a bot vote.""" + __slots__: t.Tuple[str, ...] = () + bot: int """ID of the bot the user voted for.""" is_weekend: bool @@ -376,6 +392,8 @@ class BotVoteData(VoteDataDict): class GuildVoteData(VoteDataDict): """Model that contains information about a guild vote.""" + __slots__: t.Tuple[str, ...] = () + guild: int """ID of the guild the user voted for.""" diff --git a/topgg/webhook.py b/topgg/webhook.py index 1e95fba4..0b85bd93 100644 --- a/topgg/webhook.py +++ b/topgg/webhook.py @@ -50,6 +50,8 @@ class WebhookType(enum.Enum): """An enum that represents the type of an endpoint.""" + __slots__: t.Tuple[str, ...] = () + BOT = enum.auto() """Marks the endpoint as a bot webhook.""" @@ -62,10 +64,11 @@ class WebhookManager(DataContainerMixin): A class for managing Top.gg webhooks. """ + __slots__: t.Tuple[str, ...] = ("__app", "_webserver", "_is_running") + __app: web.Application _webserver: web.TCPSite - _is_closed: bool - __slots__ = ("__app", "_webserver", "_is_running") + _is_running: bool def __init__(self) -> None: super().__init__() @@ -173,7 +176,7 @@ class WebhookEndpoint: A helper class to setup webhook endpoint. """ - __slots__ = ("_callback", "_auth", "_route", "_type") + __slots__: t.Tuple[str, ...] = ("_callback", "_auth", "_route", "_type") def __init__(self) -> None: self._auth = "" @@ -298,7 +301,7 @@ def on_vote(vote_data: topgg.BotVoteData): ... endpoint.add_to_manager() """ - __slots__ = ("manager",) + __slots__: t.Tuple[str, ...] = ("manager",) def __init__(self, manager: WebhookManager): super().__init__() From e6d3f3b8a6c9b74f8b59a7c52b5484f84a194790 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sun, 29 Sep 2024 15:58:47 +0700 Subject: [PATCH 042/126] meta: use triple quotes for MIT license comments [skip ci] --- examples/discordpy_example/__main__.py | 48 ++++++++++--------- .../discordpy_example/callbacks/autopost.py | 47 +++++++++--------- .../discordpy_example/callbacks/webhook.py | 45 ++++++++--------- examples/hikari_example/__main__.py | 48 ++++++++++--------- examples/hikari_example/callbacks/autopost.py | 47 +++++++++--------- examples/hikari_example/callbacks/webhook.py | 46 +++++++++--------- examples/hikari_example/events/autopost.py | 45 +++++++++-------- examples/hikari_example/events/webhook.py | 38 ++++++++------- topgg/autopost.py | 44 +++++++++-------- topgg/client.py | 46 +++++++++--------- topgg/data.py | 44 +++++++++-------- topgg/errors.py | 44 +++++++++-------- topgg/http.py | 46 +++++++++--------- topgg/ratelimiter.py | 44 +++++++++-------- topgg/types.py | 44 +++++++++-------- topgg/webhook.py | 46 +++++++++--------- 16 files changed, 375 insertions(+), 347 deletions(-) diff --git a/examples/discordpy_example/__main__.py b/examples/discordpy_example/__main__.py index 70771708..2859f8ab 100644 --- a/examples/discordpy_example/__main__.py +++ b/examples/discordpy_example/__main__.py @@ -1,27 +1,29 @@ -# The MIT License (MIT) - -# Copyright (c) 2021 Norizon -# Copyright (c) 2024 null8626 - -# 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 +""" +The MIT License (MIT) + +Copyright (c) 2021 Norizon +Copyright (c) 2024 null8626 + +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 from .callbacks import autopost, webhook diff --git a/examples/discordpy_example/callbacks/autopost.py b/examples/discordpy_example/callbacks/autopost.py index e6592a6d..2ca58b78 100644 --- a/examples/discordpy_example/callbacks/autopost.py +++ b/examples/discordpy_example/callbacks/autopost.py @@ -1,28 +1,29 @@ -# 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 +""" +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 - import topgg diff --git a/examples/discordpy_example/callbacks/webhook.py b/examples/discordpy_example/callbacks/webhook.py index 358753c1..2207547c 100644 --- a/examples/discordpy_example/callbacks/webhook.py +++ b/examples/discordpy_example/callbacks/webhook.py @@ -1,27 +1,28 @@ -# 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/hikari_example/__main__.py b/examples/hikari_example/__main__.py index 82da8ba0..e962f95a 100644 --- a/examples/hikari_example/__main__.py +++ b/examples/hikari_example/__main__.py @@ -1,27 +1,29 @@ -# The MIT License (MIT) - -# Copyright (c) 2021 Norizon -# Copyright (c) 2024 null8626 - -# 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 +""" +The MIT License (MIT) + +Copyright (c) 2021 Norizon +Copyright (c) 2024 null8626 + +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 from .callbacks import autopost, webhook diff --git a/examples/hikari_example/callbacks/autopost.py b/examples/hikari_example/callbacks/autopost.py index 6bab89f2..d50080e2 100644 --- a/examples/hikari_example/callbacks/autopost.py +++ b/examples/hikari_example/callbacks/autopost.py @@ -1,28 +1,29 @@ -# 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 +""" +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 - import topgg # from ..events.autopost import AutoPostErrorEvent, AutoPostSuccessEvent diff --git a/examples/hikari_example/callbacks/webhook.py b/examples/hikari_example/callbacks/webhook.py index 49daedaa..222f9445 100644 --- a/examples/hikari_example/callbacks/webhook.py +++ b/examples/hikari_example/callbacks/webhook.py @@ -1,29 +1,29 @@ -# 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 topgg - # import hikari 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..8183b742 100644 --- a/examples/hikari_example/events/webhook.py +++ b/examples/hikari_example/events/webhook.py @@ -1,27 +1,29 @@ -# The MIT License (MIT) +""" +The MIT License (MIT) -# Copyright (c) 2021 Norizon +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: +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 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 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 - import topgg diff --git a/topgg/autopost.py b/topgg/autopost.py index aae19eaa..cfc16fca 100644 --- a/topgg/autopost.py +++ b/topgg/autopost.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. +""" __all__ = ("AutoPoster",) diff --git a/topgg/client.py b/topgg/client.py index 9622018b..e7c32dbe 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -1,25 +1,27 @@ -# The MIT License (MIT) - -# Copyright (c) 2021 Assanali Mukhanov -# Copyright (c) 2024 null8626 - -# 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 Assanali Mukhanov +Copyright (c) 2024 null8626 + +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__ = ("DBLClient",) diff --git a/topgg/data.py b/topgg/data.py index 639c8d21..0d05668b 100644 --- a/topgg/data.py +++ b/topgg/data.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. +""" __all__ = ( "data", diff --git a/topgg/errors.py b/topgg/errors.py index 914e236a..f4381c0d 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -1,24 +1,26 @@ -# 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. +""" +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__ = ( "TopGGException", diff --git a/topgg/http.py b/topgg/http.py index 7c09d5dd..e2de080c 100644 --- a/topgg/http.py +++ b/topgg/http.py @@ -1,25 +1,27 @@ -# The MIT License (MIT) - -# Copyright (c) 2021 Assanali Mukhanov -# Copyright (c) 2024 null8626 - -# 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 Assanali Mukhanov +Copyright (c) 2024 null8626 + +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__ = ("HTTPClient",) diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index b95e5e92..9323081b 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -1,24 +1,26 @@ -# 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. +""" +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. +""" import asyncio import collections diff --git a/topgg/types.py b/topgg/types.py index 746a1099..6aee3e13 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -1,24 +1,26 @@ -# 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. +""" +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") diff --git a/topgg/webhook.py b/topgg/webhook.py index 0b85bd93..46b94ccd 100644 --- a/topgg/webhook.py +++ b/topgg/webhook.py @@ -1,25 +1,27 @@ -# The MIT License (MIT) - -# Copyright (c) 2021 Assanali Mukhanov -# Copyright (c) 2024 null8626 - -# 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 Assanali Mukhanov +Copyright (c) 2024 null8626 + +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", From 6a4ca42093c5e230e1877e69562b64ceeed033fb Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 30 Sep 2024 10:23:08 +0700 Subject: [PATCH 043/126] deps: bump minimum aiohttp version to version 3.10.8 --- pyproject.toml | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 112daa49..d1765e4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.rst" license = { text = "MIT" } authors = [{ name = "null8626" }, { name = "Top.gg" }] keywords = ["discord", "discord-bot", "topgg"] -dependencies = ["aiohttp>=3.10.7"] +dependencies = ["aiohttp>=3.10.8"] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", diff --git a/requirements.txt b/requirements.txt index 04c8fa4c..f7e0272f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -aiohttp>=3.10.7 +aiohttp>=3.10.8 From abe17ca3e58e49cd804bb704f3216a8de3c7ddb7 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 8 Oct 2024 11:31:44 +0700 Subject: [PATCH 044/126] meta: add support for python 3.13 --- .github/workflows/python-package.yml | 2 +- .github/workflows/python-publish.yml | 2 +- .readthedocs.yml | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 473cba85..5c782b4c 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ 3.8, 3.9, '3.10', 3.11, 3.12 ] + python-version: [ 3.8, 3.9, '3.10', 3.11, 3.12, 3.13 ] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 5ff91b04..684f44ad 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: '3.12' + python-version: '3.13' - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.readthedocs.yml b/.readthedocs.yml index 35fe3221..6780bd88 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -7,7 +7,7 @@ build: image: latest python: - version: 3.12 + version: 3.13 install: - requirements: requirements.txt - requirements: requirements-docs.txt diff --git a/pyproject.toml b/pyproject.toml index d1765e4b..c5b4bd82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.rst" license = { text = "MIT" } authors = [{ name = "null8626" }, { name = "Top.gg" }] keywords = ["discord", "discord-bot", "topgg"] -dependencies = ["aiohttp>=3.10.8"] +dependencies = ["aiohttp>=3.10.9"] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", diff --git a/requirements.txt b/requirements.txt index f7e0272f..9b5cf4af 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -aiohttp>=3.10.8 +aiohttp>=3.10.9 From e535b6290d1771c7d1cb180d7bdc5a78218f07cc Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 8 Oct 2024 11:33:58 +0700 Subject: [PATCH 045/126] meta: add python 3.13 classifier --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index c5b4bd82..09b654fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Internet", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules", From 286a46ca2bc69963beca21e7c4cc094beb600011 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 14 Oct 2024 19:02:24 +0700 Subject: [PATCH 046/126] doc: add ~ prefix --- topgg/client.py | 4 ++-- topgg/webhook.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index e7c32dbe..80054e89 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -49,10 +49,10 @@ class DBLClient(DataContainerMixin): token (:obj:`str`): Your bot's Top.gg API Token. Keyword Args: - session (:class:`aiohttp.ClientSession`) + session (:class:`~aiohttp.ClientSession`) An `aiohttp session`_ to use for requests to the API. **kwargs: - Arbitrary kwargs to be passed to :class:`aiohttp.ClientSession` if session was not provided. + Arbitrary kwargs to be passed to :class:`~aiohttp.ClientSession` if session was not provided. """ __slots__: t.Tuple[str, ...] = ("http", "bot_id", "_token", "_is_closed", "_autopost") diff --git a/topgg/webhook.py b/topgg/webhook.py index 46b94ccd..b3add959 100644 --- a/topgg/webhook.py +++ b/topgg/webhook.py @@ -143,7 +143,7 @@ def app(self) -> web.Application: """Returns the internal web application that handles webhook requests. Returns: - :class:`aiohttp.web.Application`: + :class:`~aiohttp.web.Application`: The internal web application. """ return self.__app From d10e7af99cea9af006af06784211f0a4c2083d0a Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 14 Oct 2024 19:55:34 +0700 Subject: [PATCH 047/126] doc: add raw API reference redirect page --- docs/conf.py | 2 +- docs/index.rst | 3 ++- docs/raw-api-reference.rst | 5 +++++ 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 docs/raw-api-reference.rst diff --git a/docs/conf.py b/docs/conf.py index 50eaf9d5..eddd0879 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -43,13 +43,13 @@ 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), } redirects = { "repository": "https://github.com/top-gg-community/python-sdk", "support": "https://discord.gg/dbl", + "raw-api-reference": "https://docs.top.gg/docs/", "api/index": "autopost.html", "examples/index": "discord_py.html", "examples/discord_py": "https://github.com/Top-gg-Community/python-sdk/tree/master/examples/discordpy_example", diff --git a/docs/index.rst b/docs/index.rst index f2aebe23..09e04bd4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -50,4 +50,5 @@ Additional information examples/index.rst whats_new repository - support \ No newline at end of file + support + raw-api-reference \ No newline at end of file diff --git a/docs/raw-api-reference.rst b/docs/raw-api-reference.rst new file mode 100644 index 00000000..7486b294 --- /dev/null +++ b/docs/raw-api-reference.rst @@ -0,0 +1,5 @@ +================= +Raw API reference +================= + +You should be redirected in a few moments. Otherwise, click here: https://docs.top.gg/docs/ \ No newline at end of file From 3707ff5579909fb44930eacb5bb9a972eef86281 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 15 Oct 2024 14:06:13 +0700 Subject: [PATCH 048/126] doc: simplify documentation hyperlinks [skip ci] --- docs/whats_new.rst | 60 +++++++++++++++++++++++----------------------- topgg/errors.py | 4 ++-- topgg/types.py | 2 +- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/docs/whats_new.rst b/docs/whats_new.rst index d506500b..6faad770 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -9,34 +9,34 @@ v2.0.1 * Added Python 3.12 support (:issue:`78`) * Dropped Python 3.6 and 3.7 support (:issue:`75`) * Removed the need to manually set a ``default_bot_id`` property -* :attr:`~.BotData.def_avatar` is now an optional string -* :meth:`~.DBLClient.get_bots` is now deprecated -* :meth:`~.DBLClient.get_guild_count` no longer accepts a ``bot_id`` argument -* :meth:`~.DBLClient.get_bot_votes` no longer raises a :class:`~.ClientException` without a ``default_bot_id`` property -* :meth:`~.DBLClient.get_bot_info` no longer raises a :class:`~.ClientException` without a ``default_bot_id`` property -* :meth:`~.DBLClient.generate_widget` no longer raises a :class:`~.ClientException` without a ``default_bot_id`` property +* :attr:`.BotData.def_avatar` is now an optional string +* :meth:`.DBLClient.get_bots` is now deprecated +* :meth:`.DBLClient.get_guild_count` no longer accepts a ``bot_id`` argument +* :meth:`.DBLClient.get_bot_votes` no longer raises a :class:`.ClientException` without a ``default_bot_id`` property +* :meth:`.DBLClient.get_bot_info` no longer raises a :class:`.ClientException` without a ``default_bot_id`` property +* :meth:`.DBLClient.generate_widget` no longer raises a :class:`.ClientException` without a ``default_bot_id`` property * Documentation overhaul v2.0.0a ======= -* :obj:`~.DBLClient` now doesn't take in ``discord.Client`` instance +* :obj:`.DBLClient` now doesn't take in ``discord.Client`` instance * Introduced new `autopost `__ and `data injection `__ API * `Webhook `__ API breaking changes * No longer depends on any Discord API wrapper -* :obj:`~.GuildVoteData` alias +* :obj:`.GuildVoteData` alias v1.4.0 ====== -* The type of data passed to ``on_dbl_vote`` has been changed from :class:`~.dict` to :obj:`BotVoteData` -* The type of data passed to ``on_dsl_vote`` has been changed from :class:`~.dict` to :obj:`ServerVoteData` +* The type of data passed to ``on_dbl_vote`` has been changed from :class:`.dict` to :obj:`BotVoteData` +* The type of data passed to ``on_dsl_vote`` has been changed from :class:`.dict` to :obj:`ServerVoteData` v1.3.0 ====== * Introduced `global ratelimiter `__ to follow Top.gg global ratelimits - * Fixed an :exc:`AttributeError` raised by :meth:`~.HTTPClient.request` + * Fixed an :exc:`AttributeError` raised by :meth:`.HTTPClient.request` * `Resource-specific ratelimit `__ is now actually resource-specific @@ -44,41 +44,41 @@ v1.2.0 ====== * Introduced global ratelimiter along with bot endpoints ratelimiter -* Follow consistency with typing in :class:`~.HTTPClient` and :class:`~.DBLClient` along with updated docstrings (:issue:`55`) +* Follow consistency with typing in :class:`.HTTPClient` and :class:`.DBLClient` along with updated docstrings (:issue:`55`) v1.1.0 ====== * Introduced `data models `__ - * :meth:`~.DBLClient.get_bot_votes` now returns a list of :class:`~.BriefUserData` objects + * :meth:`.DBLClient.get_bot_votes` now returns a list of :class:`.BriefUserData` objects - * :meth:`~.DBLClient.get_bot_info` now returns a :class:`~.BotData` object + * :meth:`.DBLClient.get_bot_info` now returns a :class:`.BotData` object - * :meth:`~.DBLClient.get_guild_count` now returns a :class:`~.BotStatsData` object + * :meth:`.DBLClient.get_guild_count` now returns a :class:`.BotStatsData` object - * :meth:`~.DBLClient.get_user_info` now returns a :class:`~.UserData` object + * :meth:`.DBLClient.get_user_info` now returns a :class:`.UserData` object -* :meth:`~.WebhookManager.run` now returns an :class:`~.asyncio.Task`, meaning it can now be optionally awaited +* :meth:`.WebhookManager.run` now returns an :class:`.asyncio.Task`, meaning it can now be optionally awaited v1.0.1 ====== -* :attr:`~.WebhookManager.webserver` now instead returns :class:`~.aiohttp.web.Application` for ease of use +* :attr:`.WebhookManager.webserver` now instead returns :class:`.aiohttp.web.Application` for ease of use v1.0.0 ====== * Renamed the module folder from ``dbl`` to ``topgg`` -* Added ``post_shard_count`` argument to :meth:`~.DBLClient.post_guild_count` +* Added ``post_shard_count`` argument to :meth:`.DBLClient.post_guild_count` * Autopost now supports automatic shard posting (:issue:`42`) * Large webhook system rework, read the :obj:`api/webhook` section for more * Added support for server webhooks -* Renamed ``DBLException`` to :class:`~.TopGGException` -* Renamed ``DBLClient.get_bot_upvotes()`` to :meth:`~.DBLClient.get_bot_votes` -* Added :meth:`~.DBLClient.generate_widget` along with the ``widgets`` section in the documentation +* Renamed ``DBLException`` to :class:`.TopGGException` +* Renamed ``DBLClient.get_bot_upvotes()`` to :meth:`.DBLClient.get_bot_votes` +* Added :meth:`.DBLClient.generate_widget` along with the ``widgets`` section in the documentation * Implemented a properly working ratelimiter * Added :func:`on_autopost_error` * All autopost events now follow ``on_autopost_x`` naming format, e.g. :func:`on_autopost_error`, :func:`on_autopost_success` @@ -87,7 +87,7 @@ v1.0.0 v0.4.0 ====== -* :meth:`~.DBLClient.post_guild_count` now supports a custom ``guild_count`` argument, which accepts either an integer or list of integers +* :meth:`.DBLClient.post_guild_count` now supports a custom ``guild_count`` argument, which accepts either an integer or list of integers * Reworked how shard info is posted * Removed ``InvalidArgument`` and ``ConnectionClosed`` exceptions * Added ``ServerError`` exception @@ -96,12 +96,12 @@ v0.3.3 ====== * Internal changes regarding support of Top.gg migration -* Fixed errors raised when using :meth:`~.DBLClient.close` without built-in webhook +* Fixed errors raised when using :meth:`.DBLClient.close` without built-in webhook v0.3.2 ====== -* ``Client`` class has been renamed to :class:`~.DBLClient` +* ``Client`` class has been renamed to :class:`.DBLClient` v0.3.1 ====== @@ -113,7 +113,7 @@ v0.3.1 v0.3.0 ====== -* :class:`~.DBLClient` now has ``autopost`` kwarg that will post server count automatically every 30 minutes +* :class:`.DBLClient` now has ``autopost`` kwarg that will post server count automatically every 30 minutes * Fixed code 403 errors * Added ``on_dbl_vote``, an event that is called when you test your webhook * Added ``on_dbl_test``, an event that is called when someone tests your webhook @@ -123,7 +123,7 @@ v0.2.1 * Added webhook * Removed support for discord.py versions lower than 1.0.0 -* Made :meth:`~.DBLClient.get_weekend_status` return a boolean value +* Made :meth:`.DBLClient.get_weekend_status` return a boolean value * Added webhook example in README * Removed ``post_server_count`` and ``get_server_count`` @@ -138,9 +138,9 @@ v0.2.0 * Made ``get_server_count`` an alias for ``get_guild_count`` -* Added :meth:`~.DBLClient.get_weekend_status` -* Removed all parameters from :meth:`~.DBLClient.get_upvote_info` -* Added limit to :meth:`~.DBLClient.get_bots` +* Added :meth:`.DBLClient.get_weekend_status` +* Removed all parameters from :meth:`.DBLClient.get_upvote_info` +* Added limit to :meth:`.DBLClient.get_bots` * Fixed example in README v0.1.6 diff --git a/topgg/errors.py b/topgg/errors.py index f4381c0d..e15d39be 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -48,14 +48,14 @@ class TopGGException(Exception): class ClientException(TopGGException): - """Exception that's thrown when an operation in the :class:`~.DBLClient` fails. + """Exception that's thrown when an operation in the :class:`.DBLClient` fails. These are usually for exceptions that happened due to user input. """ class ClientStateException(ClientException): - """Exception that's thrown when an operation happens in a closed :obj:`~.DBLClient` instance.""" + """Exception that's thrown when an operation happens in a closed :obj:`.DBLClient` instance.""" class HTTPException(TopGGException): diff --git a/topgg/types.py b/topgg/types.py index 6aee3e13..f2204c5a 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -374,7 +374,7 @@ class VoteDataDict(DataDict[str, t.Any]): user: int """ID of the voter.""" query: DataDict - """Query parameters in :obj:`~.DataDict`.""" + """Query parameters in :obj:`.DataDict`.""" def __init__(self, **kwargs: t.Any): super().__init__(**parse_vote_dict(kwargs)) From af94e5ea1d654c8074ad9faf5d0b9432456f2842 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 19 Oct 2024 12:35:21 +0700 Subject: [PATCH 049/126] refactor: use fromisoformat instead of strptime --- topgg/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/topgg/types.py b/topgg/types.py index f2204c5a..ea1b831f 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -90,7 +90,7 @@ 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") + data["date"] = datetime.fromisoformat(data["date"]) if data.get("owners"): data["owners"] = [int(e) for e in data["owners"]] From 0bf758fcb55e3aeef1d6850a1552cc1dbb0ce099 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 24 Oct 2024 12:23:08 +0700 Subject: [PATCH 050/126] doc: reorder documentation tabs --- docs/index.rst | 4 ++-- docs/{support.rst => support-server.rst} | 0 docs/{whats_new.rst => whats-new.rst} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename docs/{support.rst => support-server.rst} (100%) rename docs/{whats_new.rst => whats-new.rst} (100%) diff --git a/docs/index.rst b/docs/index.rst index 09e04bd4..1ce0a3dd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,7 +48,7 @@ Additional information api/index.rst examples/index.rst - whats_new + whats-new + support-server repository - support raw-api-reference \ No newline at end of file diff --git a/docs/support.rst b/docs/support-server.rst similarity index 100% rename from docs/support.rst rename to docs/support-server.rst diff --git a/docs/whats_new.rst b/docs/whats-new.rst similarity index 100% rename from docs/whats_new.rst rename to docs/whats-new.rst From 803a7a17df77e3f8206492f551988f5df7085341 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 27 Nov 2024 18:20:32 +0700 Subject: [PATCH 051/126] fix: remove self.__app's None assignment --- topgg/webhook.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/topgg/webhook.py b/topgg/webhook.py index b3add959..9e2bc260 100644 --- a/topgg/webhook.py +++ b/topgg/webhook.py @@ -75,7 +75,7 @@ class WebhookManager(DataContainerMixin): def __init__(self) -> None: super().__init__() - self.__app = None + self.__app = web.Application() self._is_running = False @t.overload @@ -126,7 +126,6 @@ async def start(self, port: int) -> None: The port to run the webhook on. """ - self.__app = web.Application() runner = web.AppRunner(self.__app) await runner.setup() self._webserver = web.TCPSite(runner, "0.0.0.0", port) From 6e0eb06c7e2db413b5c43ed75a49b258e4ed76ea Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 27 Nov 2024 18:25:43 +0700 Subject: [PATCH 052/126] deps: bump aiohttp version to 3.11.7 --- pyproject.toml | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 09b654fc..899bab75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.rst" license = { text = "MIT" } authors = [{ name = "null8626" }, { name = "Top.gg" }] keywords = ["discord", "discord-bot", "topgg"] -dependencies = ["aiohttp>=3.10.9"] +dependencies = ["aiohttp>=3.11.7"] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", diff --git a/requirements.txt b/requirements.txt index 9b5cf4af..a107e820 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -aiohttp>=3.10.9 +aiohttp>=3.11.7 From 1d6da7de4f78564c90fae59191901c6182a2cd3d Mon Sep 17 00:00:00 2001 From: null8626 Date: Fri, 6 Dec 2024 17:45:24 +0700 Subject: [PATCH 053/126] deps: bump aiohttp dependency to version 3.11.10 --- pyproject.toml | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 899bab75..9580ebb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.rst" license = { text = "MIT" } authors = [{ name = "null8626" }, { name = "Top.gg" }] keywords = ["discord", "discord-bot", "topgg"] -dependencies = ["aiohttp>=3.11.7"] +dependencies = ["aiohttp>=3.11.10"] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", diff --git a/requirements.txt b/requirements.txt index a107e820..cf64d0f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -aiohttp>=3.11.7 +aiohttp>=3.11.10 From d0c9da015faf33c1493d303f6c8502d827c3687d Mon Sep 17 00:00:00 2001 From: null8626 Date: Fri, 14 Feb 2025 15:39:10 +0700 Subject: [PATCH 054/126] meta: update secrets name from PYPI_PASSWORD to PYPI_TOKEN --- .github/workflows/python-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 684f44ad..d91de2dd 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -25,7 +25,7 @@ jobs: - name: Build and publish env: TWINE_USERNAME: '__token__' - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: | python setup.py sdist bdist_wheel twine upload dist/* \ No newline at end of file From 9952b208c2d8f1da7a6c7664367259e5f913d2b0 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 15 Feb 2025 09:58:07 +0700 Subject: [PATCH 055/126] fix: fix iso timestamp for python versions prior to 3.11 --- topgg/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/topgg/types.py b/topgg/types.py index ea1b831f..4d704743 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -90,7 +90,7 @@ 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.fromisoformat(data["date"]) + data["date"] = datetime.fromisoformat(data["date"].replace("Z", "+00:00")) if data.get("owners"): data["owners"] = [int(e) for e in data["owners"]] From 14809553dd7679b1312b63c738c685505f0732a5 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 15 Feb 2025 09:59:36 +0700 Subject: [PATCH 056/126] ci: remove support python 3.8 as it's now EOL meta: update pyproject.toml --- .github/workflows/python-package.yml | 2 +- pyproject.toml | 5 ++--- requirements.txt | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 5c782b4c..81ae40b8 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ 3.8, 3.9, '3.10', 3.11, 3.12, 3.13 ] + python-version: [ 3.9, '3.10', 3.11, 3.12, 3.13 ] steps: - uses: actions/checkout@v2 diff --git a/pyproject.toml b/pyproject.toml index 9580ebb6..a75debf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.rst" license = { text = "MIT" } authors = [{ name = "null8626" }, { name = "Top.gg" }] keywords = ["discord", "discord-bot", "topgg"] -dependencies = ["aiohttp>=3.11.10"] +dependencies = ["aiohttp>=3.11.12"] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", @@ -17,7 +17,6 @@ classifiers = [ "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -28,7 +27,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Utilities" ] -requires-python = ">=3.8" +requires-python = ">=3.9" [project.urls] Documentation = "https://topggpy.readthedocs.io/en/latest/" diff --git a/requirements.txt b/requirements.txt index cf64d0f8..4143b58b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -aiohttp>=3.11.10 +aiohttp>=3.11.12 From 8ad44a7e629594f13ae8d14610f5c22a5c218a9f Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 15 Feb 2025 11:58:23 +0700 Subject: [PATCH 057/126] ci: rewrite ci [skip ci] --- .github/workflows/python-package.yml | 38 ---------------------------- .github/workflows/python-publish.yml | 31 ----------------------- .github/workflows/release.yml | 21 +++++++++++++++ .github/workflows/test.yml | 32 +++++++++++++++++++++++ 4 files changed, 53 insertions(+), 69 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 81ae40b8..00000000 --- a/.github/workflows/python-package.yml +++ /dev/null @@ -1,38 +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.9, '3.10', 3.11, 3.12, 3.13 ] - - 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 -m pip install . - - name: Lint with ruff - run: | - ruff check - - 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 d91de2dd..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.13' - - 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: '__token__' - TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} - run: | - python setup.py sdist bdist_wheel - twine upload dist/* \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..4d603df0 --- /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@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.13 + - name: Install dependencies + run: python3 -m pip install 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..fa960764 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: Run tests +on: + push: + branches: [master] + paths: + - '.github/workflows/test.yml' + - 'topgg/**/*.py' + pull_request: + paths: + - '.github/workflows/test.yml' + - 'topgg/**/*.py' +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.9, '3.10', 3.11, 3.12, 3.13] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: python3 -m pip install . -r requirements-dev.txt + - name: Run tests + run: python3 -m pytest + - name: Install ruff + if: ${{ matrix.python-version == 3.13 }} + run: python3 -m pip install ruff + - name: Lint with ruff + if: ${{ matrix.python-version == 3.13 }} + run: python3 -m ruff check \ No newline at end of file From f93de301e6a0b0cf5fbefe2adaa1f9c30e55fd84 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 15 Feb 2025 14:55:25 +0700 Subject: [PATCH 058/126] meta: update copyright year --- LICENSE | 2 +- docs/conf.py | 2 +- examples/discordpy_example/__main__.py | 2 +- examples/hikari_example/__main__.py | 2 +- topgg/__init__.py | 2 +- topgg/client.py | 2 +- topgg/http.py | 2 +- topgg/webhook.py | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/LICENSE b/LICENSE index c4cd04ce..4624af32 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ Copyright 2021 Assanali Mukhanov & Top.gg -Copyright 2024 null8626 & Top.gg +Copyright 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: diff --git a/docs/conf.py b/docs/conf.py index eddd0879..b2308b58 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -61,7 +61,7 @@ master_doc = "index" project = "topggpy" -copyright = "2021 Assanali Mukhanov; 2024 null8626" +copyright = "2021 Assanali Mukhanov; 2024-2025 null8626" author = "null8626" release = version diff --git a/examples/discordpy_example/__main__.py b/examples/discordpy_example/__main__.py index 2859f8ab..9ae6595b 100644 --- a/examples/discordpy_example/__main__.py +++ b/examples/discordpy_example/__main__.py @@ -2,7 +2,7 @@ The MIT License (MIT) Copyright (c) 2021 Norizon -Copyright (c) 2024 null8626 +Copyright (c) 2024-2025 null8626 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), diff --git a/examples/hikari_example/__main__.py b/examples/hikari_example/__main__.py index e962f95a..f23b6b4c 100644 --- a/examples/hikari_example/__main__.py +++ b/examples/hikari_example/__main__.py @@ -2,7 +2,7 @@ The MIT License (MIT) Copyright (c) 2021 Norizon -Copyright (c) 2024 null8626 +Copyright (c) 2024-2025 null8626 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), diff --git a/topgg/__init__.py b/topgg/__init__.py index 5262f281..d3e6b2fa 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -3,7 +3,7 @@ ~~~~~~~~~~~~~~~~~~~~~~~~~ A basic wrapper for the Top.gg API. :copyright: (c) 2021 Assanali Mukhanov & Top.gg -:copyright: (c) 2024 null8626 & Top.gg +:copyright: (c) 2024-2025 null8626 & Top.gg :license: MIT, see LICENSE for more details. """ diff --git a/topgg/client.py b/topgg/client.py index 80054e89..f6ae2886 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -2,7 +2,7 @@ The MIT License (MIT) Copyright (c) 2021 Assanali Mukhanov -Copyright (c) 2024 null8626 +Copyright (c) 2024-2025 null8626 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), diff --git a/topgg/http.py b/topgg/http.py index e2de080c..87d7bf2c 100644 --- a/topgg/http.py +++ b/topgg/http.py @@ -2,7 +2,7 @@ The MIT License (MIT) Copyright (c) 2021 Assanali Mukhanov -Copyright (c) 2024 null8626 +Copyright (c) 2024-2025 null8626 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), diff --git a/topgg/webhook.py b/topgg/webhook.py index 9e2bc260..9b947d8f 100644 --- a/topgg/webhook.py +++ b/topgg/webhook.py @@ -2,7 +2,7 @@ The MIT License (MIT) Copyright (c) 2021 Assanali Mukhanov -Copyright (c) 2024 null8626 +Copyright (c) 2024-2025 null8626 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), From 0a1bbe284bc1878b2f4605aa6fc0452559de2b6d Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 15 Feb 2025 15:24:59 +0700 Subject: [PATCH 059/126] doc: add raw API documentation link and update .readthedocs.yml --- .readthedocs.yml | 16 +++++++--------- MANIFEST.in | 1 + pyproject.toml | 1 + 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 6780bd88..1bbdc13e 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,13 +1,11 @@ version: 2 - -sphinx: - configuration: docs/conf.py - build: - image: latest - + os: ubuntu-24.04 + tools: + python: '3.13' +sphinx: + configuration: docs/conf.py python: - version: 3.13 install: - - requirements: requirements.txt - - requirements: requirements-docs.txt + - requirements: requirements.txt + - requirements: requirements-docs.txt \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index e1c7d412..bf4d57b9 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -8,6 +8,7 @@ exclude .gitignore exclude .readthedocs.yml exclude mypy.ini exclude pytest.ini +exclude requirements.txt exclude requirements-dev.txt exclude requirements-docs.txt exclude ruff.toml diff --git a/pyproject.toml b/pyproject.toml index a75debf1..5534328b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ requires-python = ">=3.9" [project.urls] Documentation = "https://topggpy.readthedocs.io/en/latest/" +"Raw API Documentation" = "https://docs.top.gg/docs/" "Release notes" = "https://topggpy.readthedocs.io/en/latest/whats_new.html" Repository = "https://github.com/top-gg-community/python-sdk" "Support server" = "https://discord.gg/dbl" \ No newline at end of file From be287fe1c8372ca1bc74599e6cd6f7e537f51cf7 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 15 Feb 2025 16:50:40 +0700 Subject: [PATCH 060/126] ci: install build as well --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d603df0..bc2c4346 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: with: python-version: 3.13 - name: Install dependencies - run: python3 -m pip install twine + run: python3 -m pip install build twine - name: Build and publish env: TWINE_USERNAME: __token__ From ef7aba982d1e96131a1bce66e61feab99da05a38 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 18 Feb 2025 12:20:19 +0700 Subject: [PATCH 061/126] refactor: use time() instead --- pytest.ini | 10 +++------- topgg/ratelimiter.py | 8 ++++---- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/pytest.ini b/pytest.ini index 919cbc45..83633087 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,8 +1,4 @@ -[pytest] -xfail_strict = true -norecursedirs = docs *.egg-info .git - -filterwarnings = - ignore::DeprecationWarning - +[pytest] +xfail_strict = true +norecursedirs = docs *.egg-info .git addopts = --cov=topgg \ No newline at end of file diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index 9323081b..f8894a15 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -24,7 +24,7 @@ import asyncio import collections -from datetime import datetime +from time import time from types import TracebackType from typing import Any, Awaitable, Callable, List, Optional, Type, Tuple @@ -62,10 +62,10 @@ def __init__( async def __aenter__(self) -> "AsyncRateLimiter": async with self.__lock: if len(self.calls) >= self.max_calls: - until = datetime.utcnow().timestamp() + self.period - self._timespan + until = time() + self.period - self._timespan if self.callback: asyncio.ensure_future(self.callback(until)) - sleep_time = until - datetime.utcnow().timestamp() + sleep_time = until - time() if sleep_time > 0: await asyncio.sleep(sleep_time) return self @@ -78,7 +78,7 @@ async def __aexit__( ) -> None: async with self.__lock: # Store the last operation timestamp. - self.calls.append(datetime.utcnow().timestamp()) + self.calls.append(time()) while self._timespan >= self.period: self.calls.popleft() From bcd414adef82d55149bf2c2ed7461204c422080b Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 18 Feb 2025 12:45:46 +0700 Subject: [PATCH 062/126] refactor: use walrus operator --- topgg/autopost.py | 2 +- topgg/ratelimiter.py | 9 +++++---- topgg/types.py | 38 +++++++++++++++++--------------------- 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/topgg/autopost.py b/topgg/autopost.py index cfc16fca..49199826 100644 --- a/topgg/autopost.py +++ b/topgg/autopost.py @@ -255,7 +255,7 @@ def _fut_done_callback(self, future: "asyncio.Future") -> None: async def _internal_loop(self) -> None: try: - while 1: + while True: stats = await self.client._invoke_callback(self._stats) try: await self.client.post_guild_count(stats) diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index f8894a15..84ea5dad 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -65,8 +65,7 @@ async def __aenter__(self) -> "AsyncRateLimiter": until = time() + self.period - self._timespan if self.callback: asyncio.ensure_future(self.callback(until)) - sleep_time = until - time() - if sleep_time > 0: + if (sleep_time := until - time()) > 0: await asyncio.sleep(sleep_time) return self @@ -97,7 +96,9 @@ def __init__(self, rate_limiters: List[AsyncRateLimiter]): self.rate_limiters = rate_limiters async def __aenter__(self) -> "AsyncRateLimiterManager": - [await manager.__aenter__() for manager in self.rate_limiters] + for manager in self.rate_limiters: + await manager.__aenter__() + return self async def __aexit__( @@ -106,4 +107,4 @@ async def __aexit__( exc_val: BaseException, exc_tb: TracebackType, ) -> None: - await asyncio.gather(*[manager.__aexit__(exc_type, exc_val, exc_tb) for manager in self.rate_limiters]) + await asyncio.gather(*(manager.__aexit__(exc_type, exc_val, exc_tb) for manager in self.rate_limiters)) diff --git a/topgg/types.py b/topgg/types.py index 4d704743..46721f8d 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -41,8 +41,7 @@ def camel_to_snake(string: str) -> str: def parse_vote_dict(d: dict) -> dict: data = d.copy() - query = data.get("query", "").lstrip("?") - if query: + if query := data.get("query", "").lstrip("?"): query_dict = {k: v for k, v in [pair.split("=") for pair in query.split("&")]} data["query"] = DataDict(**query_dict) else: @@ -55,8 +54,7 @@ def parse_vote_dict(d: dict) -> dict: data["guild"] = int(data["guild"]) for key, value in data.copy().items(): - converted_key = camel_to_snake(key) - if key != converted_key: + if key != (converted_key := camel_to_snake(key)): del data[key] data[converted_key] = value @@ -67,20 +65,17 @@ 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 == "": + if value == "": value = None + elif "id" in key.lower(): + if isinstance(value, str) and value.isdigit(): + value = int(value) + else: + continue - converted_key = camel_to_snake(key) - if key != converted_key: + if key != (converted_key := camel_to_snake(key)): del data[key] + data[converted_key] = value return data @@ -89,13 +84,14 @@ def parse_dict(d: dict) -> dict: 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.fromisoformat(data["date"].replace("Z", "+00:00")) + if (date := data.get("date")) and not isinstance(date, datetime): + data["date"] = datetime.fromisoformat(date.replace("Z", "+00:00")) + + if owners := data.get("owners"): + data["owners"] = [int(e) for e in owners] - 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"]] + # TODO: remove this soon + data["guilds"] = [] for key, value in data.copy().items(): converted_key = camel_to_snake(key) From e13d619ace511f6100cc9e59d8de0ff87090f4f7 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 18 Feb 2025 13:05:13 +0700 Subject: [PATCH 063/126] feat: add deprecation warnings for posting shard-related data --- docs/whats-new.rst | 1 - topgg/autopost.py | 8 +++----- topgg/client.py | 24 +++++++++++++----------- topgg/http.py | 37 +++++++++++-------------------------- 4 files changed, 27 insertions(+), 43 deletions(-) diff --git a/docs/whats-new.rst b/docs/whats-new.rst index 6faad770..a548ba9e 100644 --- a/docs/whats-new.rst +++ b/docs/whats-new.rst @@ -10,7 +10,6 @@ v2.0.1 * Dropped Python 3.6 and 3.7 support (:issue:`75`) * Removed the need to manually set a ``default_bot_id`` property * :attr:`.BotData.def_avatar` is now an optional string -* :meth:`.DBLClient.get_bots` is now deprecated * :meth:`.DBLClient.get_guild_count` no longer accepts a ``bot_id`` argument * :meth:`.DBLClient.get_bot_votes` no longer raises a :class:`.ClientException` without a ``default_bot_id`` property * :meth:`.DBLClient.get_bot_info` no longer raises a :class:`.ClientException` without a ``default_bot_id`` property diff --git a/topgg/autopost.py b/topgg/autopost.py index 49199826..2df1d439 100644 --- a/topgg/autopost.py +++ b/topgg/autopost.py @@ -264,12 +264,11 @@ async def _internal_loop(self) -> None: if isinstance(err, errors.Unauthorized): 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: @@ -305,7 +304,7 @@ def stop(self) -> None: because this will post once before stopping as opposed to cancel immediately. """ if not self.is_running: - return None + return self._stopping = True @@ -322,4 +321,3 @@ def cancel(self) -> None: self._task.cancel() self._refresh_state() - return None diff --git a/topgg/client.py b/topgg/client.py index f6ae2886..57ca47a5 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -154,13 +154,22 @@ async def post_guild_count( If the client has been closed. """ if stats: + warnings.warn( + "Using stats no longer has a use by Top.gg API v0. Soon, all you need is just your bot's server count.", + DeprecationWarning, + ) + guild_count = stats.guild_count - shard_count = stats.shard_count - shard_id = stats.shard_id + + if stats.shard_count or stats.shard_id: + warnings.warn("Posting shard-related data no longer has a use by Top.gg API v0.", DeprecationWarning) elif guild_count is None: - raise TypeError("stats or guild_count must be provided.") + raise TypeError("guild_count must be provided.") + elif shard_count or shard_id: + warnings.warn("Posting shard-related data no longer has a use by Top.gg API v0.", DeprecationWarning) + await self._ensure_session() - await self.http.post_guild_count(guild_count, shard_count, shard_id) + await self.http.post_guild_count(guild_count) async def get_guild_count(self) -> types.BotStatsData: """Gets this bot's guild count and shard info from Top.gg. @@ -225,13 +234,6 @@ async def get_bots( search: t.Optional[t.Dict[str, t.Any]] = None, fields: t.Optional[t.List[str]] = None, ) -> types.DataDict[str, t.Any]: - """ - Warning: - This function is deprecated. - """ - - warnings.warn("get_bots is now deprecated.", DeprecationWarning) - sort = sort or "" search = search or {} fields = fields or [] diff --git a/topgg/http.py b/topgg/http.py index 87d7bf2c..1b05c280 100644 --- a/topgg/http.py +++ b/topgg/http.py @@ -30,7 +30,7 @@ import logging import sys import warnings -from datetime import datetime +from time import time from typing import Any, Coroutine, Dict, Iterable, List, Optional, Sequence, Union, cast, Tuple import aiohttp @@ -175,20 +175,9 @@ async def close(self) -> None: if self._own_session: await self.session.close() - async def post_guild_count( - self, - guild_count: Optional[Union[int, List[int]]], - shard_count: Optional[int], - shard_id: Optional[int], - ) -> None: + async def post_guild_count(self, guild_count: Optional[Union[int, List[int]]]) -> None: """Posts bot's guild count and shards info on Top.gg.""" - payload = {"server_count": guild_count} - if shard_count: - payload["shard_count"] = shard_count - if shard_id: - payload["shard_id"] = shard_id - - await self.request("POST", "/bots/stats", json=payload) + await self.request("POST", "/bots/stats", json={"server_count": guild_count}) def get_weekend_status(self) -> Coroutine[Any, Any, dict]: """Gets the weekend status from Top.gg.""" @@ -214,16 +203,9 @@ def get_bots( search: Dict[str, str], fields: Sequence[str], ) -> Coroutine[Any, Any, dict]: - """ - Warning: - This function is deprecated. - """ - - warnings.warn("get_bots is now deprecated.", DeprecationWarning) - limit = min(limit, 500) fields = ", ".join(fields) - search = " ".join([f"{field}: {value}" for field, value in search.items()]) + search = " ".join(f"{field}: {value}" for field, value in search.items()) return self.request( "GET", @@ -248,13 +230,16 @@ def get_user_vote(self, bot_id: int, user_id: int) -> Coroutine[Any, Any, dict]: async def _rate_limit_handler(until: float) -> None: """Handles the displayed message when we are ratelimited.""" - duration = round(until - datetime.utcnow().timestamp()) + duration = round(until - time()) mins = duration / 60 fmt = "We have exhausted a ratelimit quota. Retrying in %.2f seconds (%.3f minutes)." _LOGGER.warning(fmt, duration, mins) def to_json(obj: Any) -> str: - if json.__name__ == "ujson": - return json.dumps(obj, ensure_ascii=True) - return json.dumps(obj, separators=(",", ":"), ensure_ascii=True) + kwargs = {"ensure_ascii": True} + + if json.__name__ != "ujson": + kwargs["separators"] = (",", ":") + + return json.dumps(obj, **kwargs) From 48e9c970d014e79f88afd69273c8e00df9e19e1d Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 18 Feb 2025 13:20:16 +0700 Subject: [PATCH 064/126] feat: deprecated shard-related things --- examples/hikari_example/callbacks/autopost.py | 2 +- tests/test_type.py | 7 +--- topgg/autopost.py | 2 +- topgg/client.py | 14 +++---- topgg/data.py | 2 +- topgg/types.py | 38 +++++++++++++------ 6 files changed, 35 insertions(+), 30 deletions(-) diff --git a/examples/hikari_example/callbacks/autopost.py b/examples/hikari_example/callbacks/autopost.py index d50080e2..e09a0165 100644 --- a/examples/hikari_example/callbacks/autopost.py +++ b/examples/hikari_example/callbacks/autopost.py @@ -58,4 +58,4 @@ def on_autopost_error( def stats(app: hikari.GatewayBot = topgg.data(hikari.GatewayBot)): - return topgg.StatsWrapper(guild_count=len(app.cache.get_guilds_view()), shard_count=app.shard_count) + return topgg.StatsWrapper(guild_count=len(app.cache.get_guilds_view())) diff --git a/tests/test_type.py b/tests/test_type.py index 8cea66c5..c66c81bf 100644 --- a/tests/test_type.py +++ b/tests/test_type.py @@ -173,12 +173,7 @@ def test_server_vote_data_fields(server_vote_data: types.BotVoteData) -> None: 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 - assert isinstance(bot_stats_data.shards, list) - if bot_stats_data.shards: - for shard in bot_stats_data.shards: - assert isinstance(shard, int) + assert isinstance(bot_stats_data["server_count"], int) or bot_stats_data["server_count"] is None def test_user_data_attrs(user_data: types.UserData) -> None: diff --git a/topgg/autopost.py b/topgg/autopost.py index 2df1d439..28a2abf8 100644 --- a/topgg/autopost.py +++ b/topgg/autopost.py @@ -191,7 +191,7 @@ def stats(self, callback: t.Any = None) -> t.Any: @autopost.stats() def get_stats(client: Client = topgg.data(Client)): - return topgg.StatsWrapper(guild_count=len(client.guilds), shard_count=len(client.shards)) + return topgg.StatsWrapper(guild_count=len(client.guilds)) # somewhere after the event loop has started diff --git a/topgg/client.py b/topgg/client.py index 57ca47a5..b3e5eb4d 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -127,7 +127,7 @@ async def post_guild_count( shard_count: t.Any = None, shard_id: t.Any = None, ) -> None: - """Posts your bot's guild count and shards info to Top.gg. + """Posts your bot's guild count to Top.gg. .. _0 based indexing : https://en.wikipedia.org/wiki/Zero-based_numbering @@ -136,16 +136,12 @@ async def post_guild_count( Args: stats (:obj:`~.types.StatsWrapper`) - An instance of StatsWrapper containing guild_count, shard_count, and shard_id. + An instance of StatsWrapper containing guild_count. Keyword Arguments: guild_count (Optional[Union[:obj:`int`, List[:obj:`int`]]]) - Number of guilds the bot is in. Applies the number to a shard instead if shards are specified. + Number of guilds the bot is in. If not specified, length of provided client's property `.guilds` will be posted. - shard_count (Optional[:obj:`int`]) - The total number of shards. - shard_id (Optional[:obj:`int`]) - The index of the current shard. Top.gg uses `0 based indexing`_ for shards. Raises: TypeError @@ -172,11 +168,11 @@ async def post_guild_count( await self.http.post_guild_count(guild_count) async def get_guild_count(self) -> types.BotStatsData: - """Gets this bot's guild count and shard info from Top.gg. + """Gets this bot's guild count from Top.gg. Returns: :obj:`~.types.BotStatsData`: - The guild count and shards of a bot on Top.gg. + The guild count on Top.gg. Raises: :exc:`~.errors.ClientStateException` diff --git a/topgg/data.py b/topgg/data.py index 0d05668b..b21d0a4c 100644 --- a/topgg/data.py +++ b/topgg/data.py @@ -60,7 +60,7 @@ def data(type_: t.Type[T]) -> T: @autopost.stats() def get_stats(client: Client = topgg.data(Client)): - return topgg.StatsWrapper(guild_count=len(client.guilds), shard_count=len(client.shards)) + return topgg.StatsWrapper(guild_count=len(client.guilds)) """ return t.cast(T, Data(type_)) diff --git a/topgg/types.py b/topgg/types.py index 46721f8d..670b9376 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -26,6 +26,7 @@ import dataclasses import typing as t +import warnings from datetime import datetime KT = t.TypeVar("KT") @@ -115,10 +116,6 @@ def parse_bot_stats_dict(d: dict) -> dict: 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 return data @@ -270,20 +267,34 @@ def __init__(self, **kwargs: t.Any): class BotStatsData(DataDict[str, t.Any]): - """Model that contains information about a listed bot's guild and shard count.""" + """Model that contains information about a listed bot's guild count.""" __slots__: t.Tuple[str, ...] = () server_count: t.Optional[int] """The amount of servers the bot is in.""" - shards: t.List[int] - """The amount of servers the bot is in per shard.""" - shard_count: t.Optional[int] - """The amount of shards a bot has.""" def __init__(self, **kwargs: t.Any): super().__init__(**parse_bot_stats_dict(kwargs)) + @property + def shards(self) -> t.List[int]: + """DEPRECATED: No longer supported by Top.gg API v0. At the moment, this will always return an empty array.""" + + warnings.warn( + "No longer supported by Top.gg API v0. At the moment, this will always return an empty array.", + DeprecationWarning, + ) + return [] + + @property + def shard_count(self) -> t.Optional[int]: + """DEPRECATED: No longer supported by Top.gg API v0. At the moment, this will always return None.""" + + warnings.warn( + "No longer supported by Top.gg API v0. At the moment, this will always return None.", DeprecationWarning + ) + class BriefUserData(DataDict[str, t.Any]): """Model that contains brief information about a Top.gg user.""" @@ -405,7 +416,10 @@ class StatsWrapper: """The guild count.""" shard_count: t.Optional[int] = None - """The shard count.""" - shard_id: t.Optional[int] = None - """The shard ID the guild count belongs to.""" + + def __init__(self, guild_count: int, **kwargs): + if kwargs.get("shard_count") or kwargs.get("shard_id"): + warnings.warn("Posting shard-related data no longer has a use by Top.gg API v0.", DeprecationWarning) + + self.guild_count = guild_count From 542f2201e8b48eebc54b99591a24961bb28888cc Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 18 Feb 2025 13:22:05 +0700 Subject: [PATCH 065/126] ci: change pytest exception match --- tests/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_client.py b/tests/test_client.py index fa11c434..b74acea6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -41,7 +41,7 @@ async def test_HTTPClient_with_no_external_session(session: ClientSession): @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(TypeError, match="guild_count must be provided."): await client.post_guild_count() From e88d2a86cf6a7eb231e42489e6126f85575fee95 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 18 Feb 2025 14:22:34 +0700 Subject: [PATCH 066/126] feat: deprecate even more legacy API properties --- topgg/types.py | 95 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 72 insertions(+), 23 deletions(-) diff --git a/topgg/types.py b/topgg/types.py index 670b9376..7ce146f0 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -92,7 +92,10 @@ def parse_bot_dict(d: dict) -> dict: data["owners"] = [int(e) for e in owners] # TODO: remove this soon - data["guilds"] = [] + data.pop("defAvatar", None) + data.pop("discriminator", None) + data.pop("guilds", None) + data.pop("certifiedBot", None) for key, value in data.copy().items(): converted_key = camel_to_snake(key) @@ -106,6 +109,10 @@ def parse_bot_dict(d: dict) -> dict: def parse_user_dict(d: dict) -> dict: data = d.copy() + # TODO: remove this soon + data.pop("discriminator", None) + data.pop("certifiedDev", None) + data["social"] = SocialData(**data.get("social", {})) return data @@ -205,15 +212,9 @@ class BotData(DataDict[str, t.Any]): username: str """The username of the bot.""" - discriminator: str - """The discriminator of the bot.""" - avatar: t.Optional[str] """The avatar hash of the bot.""" - def_avatar: t.Optional[str] - """The avatar hash of the bot's default avatar.""" - prefix: str """The prefix of the bot.""" @@ -238,18 +239,12 @@ class BotData(DataDict[str, t.Any]): owners: t.List[int] """The IDs of the owners of the bot.""" - guilds: t.List[int] - """The guilds the bot is in.""" - invite: t.Optional[str] """The invite URL of the bot.""" date: datetime """The time the bot was added.""" - certified_bot: bool - """Whether or not the bot is certified.""" - vanity: t.Optional[str] """The vanity URL of the bot.""" @@ -265,6 +260,45 @@ class BotData(DataDict[str, t.Any]): def __init__(self, **kwargs: t.Any): super().__init__(**parse_bot_dict(kwargs)) + @property + def def_avatar(self) -> t.Optional[str]: + """DEPRECATED: def_avatar is no longer supported by Top.gg API v0. At the moment, this will always be None.""" + + warnings.warn( + "def_avatar is no longer supported by Top.gg API v0. At the moment, this will always be None.", + DeprecationWarning, + ) + + @property + def discriminator(self) -> str: + """DEPRECATED: Discriminators are no longer supported by Top.gg API v0. At the moment, this will always be '0'.""" + + warnings.warn( + "Discriminators are no longer supported by Top.gg API v0. At the moment, this will always be '0'.", + DeprecationWarning, + ) + return "0" + + @property + def guilds(self) -> t.List[int]: + """DEPRECATED: Guilds list is no longer supported by Top.gg API v0. At the moment, this will always be an empty list.""" + + warnings.warn( + "Guilds list is no longer supported by Top.gg API v0. At the moment, this will always be an empty list.", + DeprecationWarning, + ) + return [] + + @property + def certified_bot(self) -> bool: + """DEPRECATED: Certified bot is no longer supported by Top.gg API v0. At the moment, this will always be false.""" + + warnings.warn( + "Certified bot is no longer supported by Top.gg API v0. At the moment, this will always be false.", + DeprecationWarning, + ) + return False + class BotStatsData(DataDict[str, t.Any]): """Model that contains information about a listed bot's guild count.""" @@ -279,20 +313,21 @@ def __init__(self, **kwargs: t.Any): @property def shards(self) -> t.List[int]: - """DEPRECATED: No longer supported by Top.gg API v0. At the moment, this will always return an empty array.""" + """DEPRECATED: Shard-related data is no longer supported by Top.gg API v0. At the moment, this will always return an empty array.""" warnings.warn( - "No longer supported by Top.gg API v0. At the moment, this will always return an empty array.", + "Shard-related data is no longer supported by Top.gg API v0. At the moment, this will always return an empty array.", DeprecationWarning, ) return [] @property def shard_count(self) -> t.Optional[int]: - """DEPRECATED: No longer supported by Top.gg API v0. At the moment, this will always return None.""" + """DEPRECATED: Shard-related data is no longer supported by Top.gg API v0. At the moment, this will always return None.""" warnings.warn( - "No longer supported by Top.gg API v0. At the moment, this will always return None.", DeprecationWarning + "Shard-related data is no longer supported by Top.gg API v0. At the moment, this will always return None.", + DeprecationWarning, ) @@ -343,9 +378,6 @@ class UserData(DataDict[str, t.Any]): username: str """The username of the user.""" - discriminator: str - """The discriminator of the user.""" - social: SocialData """The social data of the user.""" @@ -355,9 +387,6 @@ class UserData(DataDict[str, t.Any]): supporter: bool """Whether or not the user is a supporter.""" - certified_dev: bool - """Whether or not the user is a certified dev.""" - mod: bool """Whether or not the user is a Top.gg mod.""" @@ -370,6 +399,26 @@ class UserData(DataDict[str, t.Any]): def __init__(self, **kwargs: t.Any): super().__init__(**parse_user_dict(kwargs)) + @property + def certified_dev(self) -> bool: + """DEPRECATED: Certified dev is no longer supported by Top.gg API v0. At the moment, this will always be False.""" + + warnings.warn( + "Certified dev is no longer supported by Top.gg API v0. At the moment, this will always be False.", + DeprecationWarning, + ) + return False + + @property + def discriminator(self) -> str: + """DEPRECATED: Discriminators are no longer supported by Top.gg API v0. At the moment, this will always be '0'.""" + + warnings.warn( + "Discriminators are no longer supported by Top.gg API v0. At the moment, this will always be '0'.", + DeprecationWarning, + ) + return "0" + class VoteDataDict(DataDict[str, t.Any]): """Base model that represents received information from Top.gg via webhooks.""" From 15d962a411a304014086f06cd18f5274ed049e39 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 18 Feb 2025 14:29:00 +0700 Subject: [PATCH 067/126] fix: fix tests not working because of guilds list --- tests/test_type.py | 3 +-- topgg/types.py | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/test_type.py b/tests/test_type.py index c66c81bf..f53abf6c 100644 --- a/tests/test_type.py +++ b/tests/test_type.py @@ -3,7 +3,6 @@ from topgg import types d: dict = { - "defAvatar": "6debd47ed13483642cf09e832ed0bc1b", "invite": "", "website": "https://top.gg", "support": "KYZsaFb", @@ -127,7 +126,7 @@ def test_bot_data_fields(bot_data: types.BotData) -> None: 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"): + elif attr == "owners": for item in bot_data[attr]: assert isinstance(item, int) assert bot_data.get(attr) == bot_data[attr] == getattr(bot_data, attr) diff --git a/topgg/types.py b/topgg/types.py index 7ce146f0..bea82509 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -291,10 +291,10 @@ def guilds(self) -> t.List[int]: @property def certified_bot(self) -> bool: - """DEPRECATED: Certified bot is no longer supported by Top.gg API v0. At the moment, this will always be false.""" + """DEPRECATED: Certified bot is no longer supported by Top.gg API v0. At the moment, this will always be False.""" warnings.warn( - "Certified bot is no longer supported by Top.gg API v0. At the moment, this will always be false.", + "Certified bot is no longer supported by Top.gg API v0. At the moment, this will always be False.", DeprecationWarning, ) return False @@ -313,10 +313,10 @@ def __init__(self, **kwargs: t.Any): @property def shards(self) -> t.List[int]: - """DEPRECATED: Shard-related data is no longer supported by Top.gg API v0. At the moment, this will always return an empty array.""" + """DEPRECATED: Shard-related data is no longer supported by Top.gg API v0. At the moment, this will always return an empty list.""" warnings.warn( - "Shard-related data is no longer supported by Top.gg API v0. At the moment, this will always return an empty array.", + "Shard-related data is no longer supported by Top.gg API v0. At the moment, this will always return an empty list.", DeprecationWarning, ) return [] From 48e60552e9c0beddac85eb3b5678ea936ee43825 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 18 Feb 2025 14:40:26 +0700 Subject: [PATCH 068/126] feat: notify lib of deprecation as well --- topgg/types.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/topgg/types.py b/topgg/types.py index bea82509..ed162183 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -279,6 +279,15 @@ def discriminator(self) -> str: ) return "0" + @property + def lib(self) -> t.Optional[str]: + """DEPRECATED: lib is no longer supported by Top.gg API v0. At the moment, this will always be None.""" + + warnings.warn( + "lib is no longer supported by Top.gg API v0. At the moment, this will always be None.", + DeprecationWarning, + ) + @property def guilds(self) -> t.List[int]: """DEPRECATED: Guilds list is no longer supported by Top.gg API v0. At the moment, this will always be an empty list.""" From 4a292d89f5063a5df19dfb004ef34f00f1bc4933 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 18 Feb 2025 15:07:48 +0700 Subject: [PATCH 069/126] doc: add deprecations in whats-new --- docs/whats-new.rst | 13 +++++++++++-- topgg/types.py | 20 +------------------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/docs/whats-new.rst b/docs/whats-new.rst index a548ba9e..de17639d 100644 --- a/docs/whats-new.rst +++ b/docs/whats-new.rst @@ -7,9 +7,18 @@ This page keeps a detailed human friendly rendering of what's new and changed in v2.0.1 ====== * Added Python 3.12 support (:issue:`78`) -* Dropped Python 3.6 and 3.7 support (:issue:`75`) +* Dropped Python 3.6, 3.7, and 3.8 support (:issue:`75`) * Removed the need to manually set a ``default_bot_id`` property -* :attr:`.BotData.def_avatar` is now an optional string +* Posting shard-related data is now deprecated +* :attr:`.BotData.certified_bot` is now deprecated +* :attr:`.BotData.def_avatar` is now deprecated +* :attr:`.BotData.discriminator` is now deprecated +* :attr:`.BotData.lib` is now deprecated +* :attr:`.BotData.guilds` is now deprecated +* :attr:`.BotData.shards` is now deprecated +* :attr:`.BotData.shard_count` is now deprecated +* :attr:`.UserData.certified_dev` is now deprecated +* :attr:`.UserData.discriminator` is now deprecated * :meth:`.DBLClient.get_guild_count` no longer accepts a ``bot_id`` argument * :meth:`.DBLClient.get_bot_votes` no longer raises a :class:`.ClientException` without a ``default_bot_id`` property * :meth:`.DBLClient.get_bot_info` no longer raises a :class:`.ClientException` without a ``default_bot_id`` property diff --git a/topgg/types.py b/topgg/types.py index ed162183..42553d11 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -36,7 +36,7 @@ def camel_to_snake(string: str) -> str: - return "".join(["_" + c.lower() if c.isupper() else c for c in string]).lstrip("_") + return "".join("_" + c.lower() if c.isupper() else c for c in string).lstrip("_") def parse_vote_dict(d: dict) -> dict: @@ -262,8 +262,6 @@ def __init__(self, **kwargs: t.Any): @property def def_avatar(self) -> t.Optional[str]: - """DEPRECATED: def_avatar is no longer supported by Top.gg API v0. At the moment, this will always be None.""" - warnings.warn( "def_avatar is no longer supported by Top.gg API v0. At the moment, this will always be None.", DeprecationWarning, @@ -271,8 +269,6 @@ def def_avatar(self) -> t.Optional[str]: @property def discriminator(self) -> str: - """DEPRECATED: Discriminators are no longer supported by Top.gg API v0. At the moment, this will always be '0'.""" - warnings.warn( "Discriminators are no longer supported by Top.gg API v0. At the moment, this will always be '0'.", DeprecationWarning, @@ -281,8 +277,6 @@ def discriminator(self) -> str: @property def lib(self) -> t.Optional[str]: - """DEPRECATED: lib is no longer supported by Top.gg API v0. At the moment, this will always be None.""" - warnings.warn( "lib is no longer supported by Top.gg API v0. At the moment, this will always be None.", DeprecationWarning, @@ -290,8 +284,6 @@ def lib(self) -> t.Optional[str]: @property def guilds(self) -> t.List[int]: - """DEPRECATED: Guilds list is no longer supported by Top.gg API v0. At the moment, this will always be an empty list.""" - warnings.warn( "Guilds list is no longer supported by Top.gg API v0. At the moment, this will always be an empty list.", DeprecationWarning, @@ -300,8 +292,6 @@ def guilds(self) -> t.List[int]: @property def certified_bot(self) -> bool: - """DEPRECATED: Certified bot is no longer supported by Top.gg API v0. At the moment, this will always be False.""" - warnings.warn( "Certified bot is no longer supported by Top.gg API v0. At the moment, this will always be False.", DeprecationWarning, @@ -322,8 +312,6 @@ def __init__(self, **kwargs: t.Any): @property def shards(self) -> t.List[int]: - """DEPRECATED: Shard-related data is no longer supported by Top.gg API v0. At the moment, this will always return an empty list.""" - warnings.warn( "Shard-related data is no longer supported by Top.gg API v0. At the moment, this will always return an empty list.", DeprecationWarning, @@ -332,8 +320,6 @@ def shards(self) -> t.List[int]: @property def shard_count(self) -> t.Optional[int]: - """DEPRECATED: Shard-related data is no longer supported by Top.gg API v0. At the moment, this will always return None.""" - warnings.warn( "Shard-related data is no longer supported by Top.gg API v0. At the moment, this will always return None.", DeprecationWarning, @@ -410,8 +396,6 @@ def __init__(self, **kwargs: t.Any): @property def certified_dev(self) -> bool: - """DEPRECATED: Certified dev is no longer supported by Top.gg API v0. At the moment, this will always be False.""" - warnings.warn( "Certified dev is no longer supported by Top.gg API v0. At the moment, this will always be False.", DeprecationWarning, @@ -420,8 +404,6 @@ def certified_dev(self) -> bool: @property def discriminator(self) -> str: - """DEPRECATED: Discriminators are no longer supported by Top.gg API v0. At the moment, this will always be '0'.""" - warnings.warn( "Discriminators are no longer supported by Top.gg API v0. At the moment, this will always be '0'.", DeprecationWarning, From 053a4389d7892805b2d3a345748433476afb4a9f Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 18 Feb 2025 15:10:13 +0700 Subject: [PATCH 070/126] doc: it's BotStatsData [skip ci] --- docs/whats-new.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/whats-new.rst b/docs/whats-new.rst index de17639d..93082ee5 100644 --- a/docs/whats-new.rst +++ b/docs/whats-new.rst @@ -15,8 +15,8 @@ v2.0.1 * :attr:`.BotData.discriminator` is now deprecated * :attr:`.BotData.lib` is now deprecated * :attr:`.BotData.guilds` is now deprecated -* :attr:`.BotData.shards` is now deprecated -* :attr:`.BotData.shard_count` is now deprecated +* :attr:`.BotStatsData.shards` is now deprecated +* :attr:`.BotStatsData.shard_count` is now deprecated * :attr:`.UserData.certified_dev` is now deprecated * :attr:`.UserData.discriminator` is now deprecated * :meth:`.DBLClient.get_guild_count` no longer accepts a ``bot_id`` argument From 3329d360174b5b86b692b2d0218306e3fa01ae06 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 19 Feb 2025 01:18:50 +0700 Subject: [PATCH 071/126] feat: add v0 branch --- topgg/client.py | 44 ++------------------------- topgg/http.py | 5 ++-- topgg/types.py | 79 ------------------------------------------------- 3 files changed, 5 insertions(+), 123 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index b3e5eb4d..d2a95f03 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -28,7 +28,6 @@ import base64 import json import typing as t -import warnings import aiohttp @@ -107,39 +106,14 @@ async def get_weekend_status(self) -> bool: data = await self.http.get_weekend_status() return data["is_weekend"] - @t.overload - async def post_guild_count(self, stats: types.StatsWrapper) -> None: ... - - @t.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: ... - - async def post_guild_count( - self, - stats: t.Any = None, - *, - guild_count: t.Any = None, - shard_count: t.Any = None, - shard_id: t.Any = None, - ) -> None: + async def post_guild_count(self, guild_count: t.Optional[int] = None) -> None: """Posts your bot's guild count to Top.gg. - .. _0 based indexing : https://en.wikipedia.org/wiki/Zero-based_numbering - Warning: You can't provide both args and kwargs at once. Args: - stats (:obj:`~.types.StatsWrapper`) - An instance of StatsWrapper containing guild_count. - - Keyword Arguments: - guild_count (Optional[Union[:obj:`int`, List[:obj:`int`]]]) + guild_count (Optional[:obj:`int`]) Number of guilds the bot is in. If not specified, length of provided client's property `.guilds` will be posted. @@ -149,20 +123,8 @@ async def post_guild_count( :exc:`~.errors.ClientStateException` If the client has been closed. """ - if stats: - warnings.warn( - "Using stats no longer has a use by Top.gg API v0. Soon, all you need is just your bot's server count.", - DeprecationWarning, - ) - - guild_count = stats.guild_count - - if stats.shard_count or stats.shard_id: - warnings.warn("Posting shard-related data no longer has a use by Top.gg API v0.", DeprecationWarning) - elif guild_count is None: + if guild_count is None: raise TypeError("guild_count must be provided.") - elif shard_count or shard_id: - warnings.warn("Posting shard-related data no longer has a use by Top.gg API v0.", DeprecationWarning) await self._ensure_session() await self.http.post_guild_count(guild_count) diff --git a/topgg/http.py b/topgg/http.py index 1b05c280..408cee5e 100644 --- a/topgg/http.py +++ b/topgg/http.py @@ -29,7 +29,6 @@ import json import logging import sys -import warnings from time import time from typing import Any, Coroutine, Dict, Iterable, List, Optional, Sequence, Union, cast, Tuple @@ -175,8 +174,8 @@ async def close(self) -> None: if self._own_session: await self.session.close() - async def post_guild_count(self, guild_count: Optional[Union[int, List[int]]]) -> None: - """Posts bot's guild count and shards info on Top.gg.""" + async def post_guild_count(self, guild_count: Optional[int]) -> None: + """Posts bot's guild count on Top.gg.""" await self.request("POST", "/bots/stats", json={"server_count": guild_count}) def get_weekend_status(self) -> Coroutine[Any, Any, dict]: diff --git a/topgg/types.py b/topgg/types.py index 42553d11..b65428ad 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -26,7 +26,6 @@ import dataclasses import typing as t -import warnings from datetime import datetime KT = t.TypeVar("KT") @@ -260,44 +259,6 @@ class BotData(DataDict[str, t.Any]): def __init__(self, **kwargs: t.Any): super().__init__(**parse_bot_dict(kwargs)) - @property - def def_avatar(self) -> t.Optional[str]: - warnings.warn( - "def_avatar is no longer supported by Top.gg API v0. At the moment, this will always be None.", - DeprecationWarning, - ) - - @property - def discriminator(self) -> str: - warnings.warn( - "Discriminators are no longer supported by Top.gg API v0. At the moment, this will always be '0'.", - DeprecationWarning, - ) - return "0" - - @property - def lib(self) -> t.Optional[str]: - warnings.warn( - "lib is no longer supported by Top.gg API v0. At the moment, this will always be None.", - DeprecationWarning, - ) - - @property - def guilds(self) -> t.List[int]: - warnings.warn( - "Guilds list is no longer supported by Top.gg API v0. At the moment, this will always be an empty list.", - DeprecationWarning, - ) - return [] - - @property - def certified_bot(self) -> bool: - warnings.warn( - "Certified bot is no longer supported by Top.gg API v0. At the moment, this will always be False.", - DeprecationWarning, - ) - return False - class BotStatsData(DataDict[str, t.Any]): """Model that contains information about a listed bot's guild count.""" @@ -310,21 +271,6 @@ class BotStatsData(DataDict[str, t.Any]): def __init__(self, **kwargs: t.Any): super().__init__(**parse_bot_stats_dict(kwargs)) - @property - def shards(self) -> t.List[int]: - warnings.warn( - "Shard-related data is no longer supported by Top.gg API v0. At the moment, this will always return an empty list.", - DeprecationWarning, - ) - return [] - - @property - def shard_count(self) -> t.Optional[int]: - warnings.warn( - "Shard-related data is no longer supported by Top.gg API v0. At the moment, this will always return None.", - DeprecationWarning, - ) - class BriefUserData(DataDict[str, t.Any]): """Model that contains brief information about a Top.gg user.""" @@ -394,22 +340,6 @@ class UserData(DataDict[str, t.Any]): def __init__(self, **kwargs: t.Any): super().__init__(**parse_user_dict(kwargs)) - @property - def certified_dev(self) -> bool: - warnings.warn( - "Certified dev is no longer supported by Top.gg API v0. At the moment, this will always be False.", - DeprecationWarning, - ) - return False - - @property - def discriminator(self) -> str: - warnings.warn( - "Discriminators are no longer supported by Top.gg API v0. At the moment, this will always be '0'.", - DeprecationWarning, - ) - return "0" - class VoteDataDict(DataDict[str, t.Any]): """Base model that represents received information from Top.gg via webhooks.""" @@ -454,12 +384,3 @@ class GuildVoteData(VoteDataDict): class StatsWrapper: guild_count: int """The guild count.""" - - shard_count: t.Optional[int] = None - shard_id: t.Optional[int] = None - - def __init__(self, guild_count: int, **kwargs): - if kwargs.get("shard_count") or kwargs.get("shard_id"): - warnings.warn("Posting shard-related data no longer has a use by Top.gg API v0.", DeprecationWarning) - - self.guild_count = guild_count From a0780770c981624298fba28b34eb9656d60ee7c1 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 19 Feb 2025 01:21:00 +0700 Subject: [PATCH 072/126] doc: update docs [skip ci] --- topgg/client.py | 4 +--- topgg/http.py | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index b3e5eb4d..6213c221 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -129,8 +129,6 @@ async def post_guild_count( ) -> None: """Posts your bot's guild count to Top.gg. - .. _0 based indexing : https://en.wikipedia.org/wiki/Zero-based_numbering - Warning: You can't provide both args and kwargs at once. @@ -139,7 +137,7 @@ async def post_guild_count( An instance of StatsWrapper containing guild_count. Keyword Arguments: - guild_count (Optional[Union[:obj:`int`, List[:obj:`int`]]]) + guild_count (Optional[:obj:`int`]) Number of guilds the bot is in. If not specified, length of provided client's property `.guilds` will be posted. diff --git a/topgg/http.py b/topgg/http.py index 1b05c280..c3411e36 100644 --- a/topgg/http.py +++ b/topgg/http.py @@ -175,8 +175,8 @@ async def close(self) -> None: if self._own_session: await self.session.close() - async def post_guild_count(self, guild_count: Optional[Union[int, List[int]]]) -> None: - """Posts bot's guild count and shards info on Top.gg.""" + async def post_guild_count(self, guild_count: Optional[int]) -> None: + """Posts bot's guild count on Top.gg.""" await self.request("POST", "/bots/stats", json={"server_count": guild_count}) def get_weekend_status(self) -> Coroutine[Any, Any, dict]: From 113cfbe2e84f06594694afd4bb4e7cd34dbfd461 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 19 Feb 2025 01:21:19 +0700 Subject: [PATCH 073/126] doc: update docs [skip ci] --- topgg/client.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index d2a95f03..3883b7a0 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -109,9 +109,6 @@ async def get_weekend_status(self) -> bool: async def post_guild_count(self, guild_count: t.Optional[int] = None) -> None: """Posts your bot's guild count to Top.gg. - Warning: - You can't provide both args and kwargs at once. - Args: guild_count (Optional[:obj:`int`]) Number of guilds the bot is in. From a0e4817ef27da47d700c69c35240047a96702ae9 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 22 Feb 2025 00:01:31 +0700 Subject: [PATCH 074/126] *: start rewrite --- .gitattributes | 1 - .github/workflows/release.yml | 21 - .github/workflows/test.yml | 32 -- .gitignore | 14 +- .readthedocs.yml | 11 - ISSUE_TEMPLATE.md | 38 -- LICENSE | 24 +- MANIFEST.in | 16 - PULL_REQUEST_TEMPLATE.md | 8 - README.md | 41 ++ README.rst | 48 -- docs/Makefile | 10 +- docs/_static/favicon.ico | Bin 15406 -> 0 bytes docs/_static/script.js | 24 + docs/_static/style.css | 4 +- docs/api/autopost.rst | 7 - docs/api/client.rst | 7 - docs/api/data.rst | 7 - docs/api/errors.rst | 6 - docs/api/index.rst | 13 - docs/api/types.rst | 42 -- docs/api/webhook.rst | 7 - docs/client.rst | 13 + docs/conf.py | 110 +--- docs/data.rst | 11 + docs/examples/discord_py.rst | 5 - docs/examples/hikari.rst | 5 - docs/examples/index.rst | 9 - docs/index.rst | 110 ++-- docs/make.bat | 71 ++- .../requirements.txt | 3 +- docs/support-server.rst | 2 +- docs/topgg.svg | 4 - docs/whats-new.rst | 185 ------- examples/discordpy_example/__main__.py | 60 --- .../discordpy_example/callbacks/autopost.py | 57 -- .../discordpy_example/callbacks/webhook.py | 40 -- examples/discordpy_example/requirements.txt | 2 - examples/hikari_example/__main__.py | 59 -- examples/hikari_example/callbacks/autopost.py | 61 --- examples/hikari_example/callbacks/webhook.py | 45 -- examples/hikari_example/events/autopost.py | 37 -- examples/hikari_example/events/webhook.py | 33 -- examples/hikari_example/requirements.txt | 2 - mypy.ini | 21 - pyproject.toml | 5 +- pytest.ini | 4 - requirements-dev.txt | 9 - requirements.txt | 1 - ruff.toml | 5 +- tests/test_autopost.py | 87 --- tests/test_client.py | 106 ---- tests/test_data_container.py | 52 -- tests/test_ratelimiter.py | 28 - tests/test_type.py | 183 ------- tests/test_webhook.py | 76 --- topgg/__init__.py | 51 +- topgg/autopost.py | 323 ----------- topgg/client.py | 503 ++++++++---------- topgg/data.py | 144 ----- topgg/errors.py | 119 ++--- topgg/http.py | 244 --------- topgg/models.py | 360 +++++++++++++ topgg/ratelimiter.py | 179 +++---- topgg/types.py | 386 -------------- topgg/webhook.py | 359 ------------- 66 files changed, 1006 insertions(+), 3544 deletions(-) delete mode 100644 .gitattributes delete mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/test.yml delete mode 100644 .readthedocs.yml delete mode 100644 ISSUE_TEMPLATE.md delete mode 100644 MANIFEST.in delete mode 100644 PULL_REQUEST_TEMPLATE.md create mode 100644 README.md delete mode 100644 README.rst delete mode 100644 docs/_static/favicon.ico delete mode 100644 docs/api/autopost.rst delete mode 100644 docs/api/client.rst delete mode 100644 docs/api/data.rst delete mode 100644 docs/api/errors.rst delete mode 100644 docs/api/index.rst delete mode 100644 docs/api/types.rst delete mode 100644 docs/api/webhook.rst create mode 100644 docs/client.rst create mode 100644 docs/data.rst delete mode 100644 docs/examples/discord_py.rst delete mode 100644 docs/examples/hikari.rst delete mode 100644 docs/examples/index.rst rename requirements-docs.txt => docs/requirements.txt (60%) delete mode 100644 docs/topgg.svg delete mode 100644 docs/whats-new.rst delete mode 100644 examples/discordpy_example/__main__.py delete mode 100644 examples/discordpy_example/callbacks/autopost.py delete mode 100644 examples/discordpy_example/callbacks/webhook.py delete mode 100644 examples/discordpy_example/requirements.txt delete mode 100644 examples/hikari_example/__main__.py delete mode 100644 examples/hikari_example/callbacks/autopost.py delete mode 100644 examples/hikari_example/callbacks/webhook.py delete mode 100644 examples/hikari_example/events/autopost.py delete mode 100644 examples/hikari_example/events/webhook.py delete mode 100644 examples/hikari_example/requirements.txt delete mode 100644 mypy.ini delete mode 100644 pytest.ini delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt delete mode 100644 tests/test_autopost.py delete mode 100644 tests/test_client.py delete mode 100644 tests/test_data_container.py delete mode 100644 tests/test_ratelimiter.py delete mode 100644 tests/test_type.py delete mode 100644 tests/test_webhook.py delete mode 100644 topgg/autopost.py delete mode 100644 topgg/data.py delete mode 100644 topgg/http.py create mode 100644 topgg/models.py delete mode 100644 topgg/types.py delete mode 100644 topgg/webhook.py diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 44b4224b..00000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -* eol=lf \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index bc2c4346..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@v4 - - uses: actions/setup-python@v5 - 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 deleted file mode 100644 index fa960764..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Run tests -on: - push: - branches: [master] - paths: - - '.github/workflows/test.yml' - - 'topgg/**/*.py' - pull_request: - paths: - - '.github/workflows/test.yml' - - 'topgg/**/*.py' -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.9, '3.10', 3.11, 3.12, 3.13] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: python3 -m pip install . -r requirements-dev.txt - - name: Run tests - run: python3 -m pytest - - name: Install ruff - if: ${{ matrix.python-version == 3.13 }} - run: python3 -m pip install ruff - - name: Lint with ruff - if: ${{ matrix.python-version == 3.13 }} - run: python3 -m ruff check \ No newline at end of file diff --git a/.gitignore b/.gitignore index 52fd1028..92119565 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,6 @@ -topggpy.egg-info/ -build/ -dist/ -docs/_build -docs/_templates -.vscode/ -.idea/ **/__pycache__/ -.coverage -.ruff_cache/ \ No newline at end of file +topstats.egg-info/ +docs/_build/ +dist/ +.ruff_cache/ +.vscode/ \ No newline at end of file diff --git a/.readthedocs.yml b/.readthedocs.yml deleted file mode 100644 index 1bbdc13e..00000000 --- a/.readthedocs.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: 2 -build: - os: ubuntu-24.04 - tools: - python: '3.13' -sphinx: - configuration: docs/conf.py -python: - install: - - requirements: requirements.txt - - requirements: requirements-docs.txt \ No newline at end of file diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md deleted file mode 100644 index 6606494b..00000000 --- a/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,38 +0,0 @@ -This issue tracker is **ONLY** used for reporting bugs with __topggpy__. Feel free to share your suggestions in the #api channel in our [Discord server](https://discord.gg/EYHTgJX)! - -For help with general Python issues, use [StackOverflow](https://stackoverflow.com), our [#development channel](https://discord.gg/EYHTgJX), or the [Python Discord server](https://discord.gg/python). - - - - -## Expected Behavior - - -## Current Behavior - - -## Development Environment - - -## Steps to Reproduce - - -1. -2. -3. -4. - - - -## Context - - - -## Possible Solution - - -## Detailed Description - - -## Possible Implementation - diff --git a/LICENSE b/LICENSE index 4624af32..3a68f837 100644 --- a/LICENSE +++ b/LICENSE @@ -1,8 +1,22 @@ -Copyright 2021 Assanali Mukhanov & Top.gg -Copyright 2024-2025 null8626 & 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 deleted file mode 100644 index bf4d57b9..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,16 +0,0 @@ -prune .github -prune .ruff_cache -prune docs -prune examples -prune tests -exclude .gitattributes -exclude .gitignore -exclude .readthedocs.yml -exclude mypy.ini -exclude pytest.ini -exclude requirements.txt -exclude requirements-dev.txt -exclude requirements-docs.txt -exclude ruff.toml -exclude ISSUE_TEMPLATE.md -exclude PULL_REQUEST_TEMPLATE.md \ No newline at end of file diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index faa06fef..00000000 --- a/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,8 +0,0 @@ - -Fixes # - -## Proposed Changes - - - - - - - diff --git a/README.md b/README.md new file mode 100644 index 00000000..ced90ed1 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# [topgg][pypi-url] [![pypi][pypi-image]][pypi-url] [![downloads][downloads-image]][pypi-url] + +[pypi-image]: https://img.shields.io/pypi/v/topggpy.svg?style=flat-square +[pypi-url]: https://pypi.org/project/topggpy/ +[downloads-image]: https://img.shields.io/pypi/dm/topggpy?style=flat-square + +The community-maintained Python API wrapper for [Top.gg](https://top.gg/) + +## Installation + +```console +pip install topggpy +``` + +## Example + +For more information, please read the [documentation](https://topggpy.readthedocs.io/en/latest/). + +```py +# import the module +import topgg + +import asyncio +import os + +async def main() -> None: + # Declare the client. to retrieve your top.gg token, see https://docs.top.gg/docs/API/@reference. + async with topgg.Client('your top.gg API token') as ts: + # Fetch a Discord bot from its ID. + bot = await tg.get_bot(432610292342587392) + + print(bot) + +if __name__ == '__main__': + # See https://stackoverflow.com/questions/45600579/asyncio-event-loop-is-closed-when-getting-loop + # for more details. + if os.name == 'nt': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + asyncio.run(main()) +``` \ No newline at end of file diff --git a/README.rst b/README.rst deleted file mode 100644 index 928930b5..00000000 --- a/README.rst +++ /dev/null @@ -1,48 +0,0 @@ -##################### -Top.gg Python Library -##################### - -.. image:: https://img.shields.io/pypi/v/topggpy.svg - :target: https://pypi.python.org/pypi/topggpy - :alt: View on PyPI -.. image:: https://img.shields.io/pypi/dm/topggpy?style=flat-square - :target: https://topggpy.readthedocs.io/en/latest/?badge=latest - :alt: Monthly PyPI downloads - -A simple API wrapper for `Top.gg `_ written in Python. - -Installation ------------- - -.. code:: bash - - pip install topggpy - -Documentation -------------- - -Documentation can be found `here `_ - -Features --------- - -* POST server count -* GET bot info, server count, upvote info -* GET user info -* GET widgets (large and small) including custom ones. See `docs.top.gg `_ for more info. -* GET weekend status -* Built-in webhook to handle Top.gg votes -* Automated server count posting -* Searching for bots via the API - -Additional information ----------------------- - -* Before using the webhook provided by this library, make sure that you have specified port open. -* Optimal values for port are between 1024 and 49151. -* If you happen to need help implementing topggpy in your bot, feel free to ask in the ``#development`` or ``#api`` channels in our `Discord server `_. - -Examples --------- - -For examples, follow the link `here `__ \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile index 32a03e5e..d4bb2cbb 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,10 +1,10 @@ # Minimal makefile for Sphinx documentation # -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -SPHINXPROJ = topggpy +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build @@ -17,4 +17,4 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/favicon.ico b/docs/_static/favicon.ico deleted file mode 100644 index ad108bb290ac60782ab9222a51717307e5772b1c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15406 zcmeHOJ!lj`6rPwEK@{U37*RMyG=>P5+r0}SYQ#bWK@iW%LSk}%NkFg=Ec8UdBDDyD zDHSb(7FOB_Vre0YHn9j;NE8VP_!E=o_wH`uX7w(!JCj>ZX5lgW^WOWu`F3u0W_C}A zCebX~+62m&$h8QO5<QX^LX6^EJYKoJTZosNh3J3}LJ(0tkD_6=;p4O_+e4jf zg(PhWN}S^|4pGJCvkr~*9iX+Ud3^DuJjiiNE8e9E&G^Y`@fYIMcG00t(sC|!dFo&0 z@6u;^9oJUNT&UM=9M60G2a|Njd+gsw%xuP``>ZMUfBLU46)*j>E{$S;5jI~_j(2EU z=JJ-R@f-vGbAHRG=%D{xwc}!uR`^W_YVx0}b}a8hqn6yy#9+NJ?w{;t6=-w?`g>>_ z`li!f%cba8m}gNKEg09^pUHjnL9?*2yh}U7Z6Jezjr}%tN8!V_-ME7Lk@LR zvtlQ;2B`nIKM(g!#Wc10bq4M_{AM4aoGbly{*2G%IK5PIK2`i0dA zOx$}V?UxH*@=z(4qy8$m(r3Bwx&5mc`ma4`ucuUegZLDCsYS&~b4P(WrJ}#8pX>*1 zX%(;v)L#K!C&3b}I0v)qBoM)FRspMkRlq7>T!CDIPUIaL@f+qLBbbL>9Z1rd@>(V} z83#9zka^4bK#g*S=k%yc96l=zz#2=T$e-5;_}%0+uEixs!xN$LBzUTWFPCfX0aGBq0%po!;7v>K*zJcSw z+&OQZ`O9`OjeBxU)&cxKFqbeE5r5`7l}*rDZu{W>Eev;#iI6{`&A$MLcHE2ajmRII z`8&=uEKr~0t!tF}S9m-``PcWPUXHc)qvnscGf|7*QrDv9kKeA+JN|0*h@5}OepA-R z@(=gik^1SDR)2l{`S}&r3Jx9N@n { } }) +const tocDrawer = document.querySelector('aside.toc-drawer') + +if (document.querySelector('section#topstats')) { + // we don't need the right sidebar on the main landing page + tocDrawer.remove() +} else { + tocDrawer.style.visibility = 'visible' + + const enumProperties = [...document.querySelectorAll('em.property')].filter(x => x.children.length === 4) + + for (const enumProperty of enumProperties) { + // we don't need to display enum values + enumProperty.children[3].innerText = '...' + } +} + +// remove related pages in the footer +document.querySelector('.related-pages').remove() + +// remove all header links +for (const headerLink of document.querySelectorAll('.headerlink')) { + headerLink.remove() +} + for (const label of document.querySelectorAll('.sidebar-container label')) { const link = [...label.parentElement.children].find(child => child.nodeName === 'A') diff --git a/docs/_static/style.css b/docs/_static/style.css index ee1e4dd5..ffaa30dd 100644 --- a/docs/_static/style.css +++ b/docs/_static/style.css @@ -1,7 +1,7 @@ body { - --color-link-underline: rgba(0, 0, 0, 0); + --color-link-underline: rgba(0, 0, 0, 0%); --color-link-underline--hover: var(--color-link); - --color-inline-code-background: rgba(0, 0, 0, 0); + --color-inline-code-background: rgba(0, 0, 0, 0%); --color-api-background-hover: var(--color-background-primary); --color-highlight-on-target: var(--color-background-primary) !important; diff --git a/docs/api/autopost.rst b/docs/api/autopost.rst deleted file mode 100644 index 0151646d..00000000 --- a/docs/api/autopost.rst +++ /dev/null @@ -1,7 +0,0 @@ -################## -Autopost reference -################## - -.. automodule:: topgg.autopost - :members: - :inherited-members: diff --git a/docs/api/client.rst b/docs/api/client.rst deleted file mode 100644 index 1d8966e8..00000000 --- a/docs/api/client.rst +++ /dev/null @@ -1,7 +0,0 @@ -################ -Client reference -################ - -.. automodule:: topgg.client - :members: - :inherited-members: \ No newline at end of file diff --git a/docs/api/data.rst b/docs/api/data.rst deleted file mode 100644 index 090494ef..00000000 --- a/docs/api/data.rst +++ /dev/null @@ -1,7 +0,0 @@ -############## -Data reference -############## - -.. automodule:: topgg.data - :members: - :inherited-members: \ No newline at end of file diff --git a/docs/api/errors.rst b/docs/api/errors.rst deleted file mode 100644 index d54af678..00000000 --- a/docs/api/errors.rst +++ /dev/null @@ -1,6 +0,0 @@ -################ -Errors reference -################ - -.. automodule:: topgg.errors - :members: \ No newline at end of file diff --git a/docs/api/index.rst b/docs/api/index.rst deleted file mode 100644 index 4c4e05bd..00000000 --- a/docs/api/index.rst +++ /dev/null @@ -1,13 +0,0 @@ -############# -API reference -############# - - .. toctree:: - :maxdepth: 2 - - autopost - client - data - errors - types - webhook \ No newline at end of file diff --git a/docs/api/types.rst b/docs/api/types.rst deleted file mode 100644 index 14b983db..00000000 --- a/docs/api/types.rst +++ /dev/null @@ -1,42 +0,0 @@ -################ -Models reference -################ - -.. automodule:: topgg.types - :members: - -.. autoclass:: topgg.types.DataDict - :members: - :inherited-members: - -.. autoclass:: topgg.types.BotData - :members: - :show-inheritance: - -.. autoclass:: topgg.types.BotStatsData - :members: - :show-inheritance: - -.. autoclass:: topgg.types.BriefUserData - :members: - :show-inheritance: - -.. autoclass:: topgg.types.UserData - :members: - :show-inheritance: - -.. autoclass:: topgg.types.SocialData - :members: - :show-inheritance: - -.. autoclass:: topgg.types.VoteDataDict - :members: - :show-inheritance: - -.. autoclass:: topgg.types.BotVoteData - :members: - :show-inheritance: - -.. autoclass:: topgg.types.GuildVoteData - :members: - :show-inheritance: \ No newline at end of file diff --git a/docs/api/webhook.rst b/docs/api/webhook.rst deleted file mode 100644 index c1b067a5..00000000 --- a/docs/api/webhook.rst +++ /dev/null @@ -1,7 +0,0 @@ -################# -Webhook reference -################# - -.. automodule:: topgg.webhook - :members: - :inherited-members: \ No newline at end of file diff --git a/docs/client.rst b/docs/client.rst new file mode 100644 index 00000000..e6ad15ca --- /dev/null +++ b/docs/client.rst @@ -0,0 +1,13 @@ +Client reference +================ + +.. autoclass:: topgg.client.Client + :members: + +.. autoclass:: topgg.errors.Error() + +.. autoclass:: topgg.errors.RequestError() + :members: + +.. autoclass:: topgg.errors.Ratelimited() + :members: \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index b2308b58..fe4faef9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,100 +1,40 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# -# topggpy documentation build configuration file, created by -# sphinx-quickstart on Thu Feb 8 18:32:44 2018. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# - -import os import sys +import os +import re -sys.path.insert(0, os.path.abspath("../")) +sys.path.insert(0, os.path.abspath('..')) -from topgg import __version__ as version +project = 'topggpy' +author = 'null8626' -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.viewcode", - "sphinx.ext.autosectionlabel", - "sphinx.ext.extlinks", - "sphinx.ext.intersphinx", - "sphinx.ext.napoleon", - "sphinx_reredirects", -] +copyright = '' +with open('../LICENSE', 'r') as f: + copyright = re.search(r'[\d\-]+ null8626', f.read()).group() -autodoc_member_order = "groupwise" +version = '' +with open('../topgg/__init__.py', 'r') as f: + version = re.search( + r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE + ).group(1) -extlinks = { - "issue": ("https://github.com/top-gg-community/python-sdk/issues/%s", "#%s"), -} +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx_reredirects'] intersphinx_mapping = { - "py": ("https://docs.python.org/3", None), - "aiohttp": ("https://docs.aiohttp.org/en/stable/", None), + 'py': ('https://docs.python.org/3', None), + 'aio': ('https://docs.aiohttp.org/en/stable/', None), } redirects = { - "repository": "https://github.com/top-gg-community/python-sdk", - "support": "https://discord.gg/dbl", - "raw-api-reference": "https://docs.top.gg/docs/", - "api/index": "autopost.html", - "examples/index": "discord_py.html", - "examples/discord_py": "https://github.com/Top-gg-Community/python-sdk/tree/master/examples/discordpy_example", - "examples/hikari": "https://github.com/Top-gg-Community/python-sdk/tree/master/examples/hikari_example", + 'support-server': 'https://discord.com/invite/dbl', + 'repository': 'https://github.com/top-gg-community/python-sdk', + 'raw-api-reference': 'https://docs.top.gg/docs/', } -releases_github_path = "top-gg-community/python-sdk" -source_suffix = ".rst" -master_doc = "index" - -project = "topggpy" -copyright = "2021 Assanali Mukhanov; 2024-2025 null8626" -author = "null8626" -release = version - -language = "en" -exclude_patterns = ["_build"] - -html_js_files = ["script.js"] html_css_files = [ - "style.css", - "https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Roboto+Mono&display=swap", -] -html_favicon = "_static/favicon.ico" -html_theme = "furo" -html_logo = "topgg.svg" -html_static_path = ["_static"] -html_show_sphinx = False -html_show_copyright = True -htmlhelp_basename = "topggpydoc" - -latex_documents = [ - (master_doc, "topggpy.tex", "topggpy Documentation", "Assanali Mukhanov", "manual"), -] - -man_pages = [(master_doc, "topggpy", "topggpy Documentation", [author], 1)] - -texinfo_documents = [ - ( - master_doc, - "topggpy", - "topggpy Documentation", - author, - "topggpy", - "One line description of project.", - "Miscellaneous", - ), + 'style.css', + 'https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Roboto+Mono&display=swap', ] +html_js_files = ['script.js'] +html_static_path = ['_static'] +html_theme = 'furo' +html_title = project diff --git a/docs/data.rst b/docs/data.rst new file mode 100644 index 00000000..b948b245 --- /dev/null +++ b/docs/data.rst @@ -0,0 +1,11 @@ +Data reference +============== + +.. autoclass:: topgg.models.Bot() + :members: + +.. autoclass:: topgg.models.BotQuery() + :members: + +.. autoclass:: topgg.models.Voter() + :members: \ No newline at end of file diff --git a/docs/examples/discord_py.rst b/docs/examples/discord_py.rst deleted file mode 100644 index e77808d8..00000000 --- a/docs/examples/discord_py.rst +++ /dev/null @@ -1,5 +0,0 @@ -================== -Discord.py example -================== - -You should be redirected in a few moments. Otherwise, click here: https://github.com/Top-gg-Community/python-sdk/tree/master/examples/discordpy_example \ No newline at end of file diff --git a/docs/examples/hikari.rst b/docs/examples/hikari.rst deleted file mode 100644 index 89fc6513..00000000 --- a/docs/examples/hikari.rst +++ /dev/null @@ -1,5 +0,0 @@ -============== -Hikari example -============== - -You should be redirected in a few moments. Otherwise, click here: https://github.com/Top-gg-Community/python-sdk/tree/master/examples/hikari_example \ No newline at end of file diff --git a/docs/examples/index.rst b/docs/examples/index.rst deleted file mode 100644 index 20573261..00000000 --- a/docs/examples/index.rst +++ /dev/null @@ -1,9 +0,0 @@ -######## -Examples -######## - - .. toctree:: - :maxdepth: 2 - - discord_py - hikari \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 1ce0a3dd..03bd4526 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,54 +1,86 @@ -.. topggpy documentation master file, created by - sphinx-quickstart on Thu Feb 8 18:32:44 2018. +.. topstats documentation master file, created by + sphinx-quickstart on Sat Oct 14 19:20:61 2024. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -##################### -Top.gg Python Library -##################### +========= +topstats_ +========= -.. image:: https://img.shields.io/pypi/v/topggpy.svg - :target: https://pypi.python.org/pypi/topggpy - :alt: View on PyPI -.. image:: https://img.shields.io/pypi/dm/topggpy?style=flat-square - :target: https://topggpy.readthedocs.io/en/latest/?badge=latest - :alt: Monthly PyPI downloads +|pypi|_ |downloads|_ -A simple API wrapper for `Top.gg `_ written in Python. +.. _topstats: https://pypi.org/project/topstats/ +.. |pypi| image:: https://img.shields.io/pypi/v/topstats.svg?style=flat-square +.. _pypi: https://pypi.org/project/topstats/ +.. |downloads| image:: https://img.shields.io/pypi/dm/topstats?style=flat-square +.. _downloads: https://pypi.org/project/topstats/ + +The community-maintained Python API wrapper for `topstats.gg `_. Installation ------------ -.. code:: bash - - pip install topggpy +.. code-block:: console -Features --------- + $ pip install topstats -* POST server count -* GET bot info, server count, upvote info -* GET user info -* GET widgets (large and small) including custom ones. See `docs.top.gg `_ for more info. -* GET weekend status -* Built-in webhook to handle Top.gg votes -* Automated server count posting -* Searching for bots via the API +Example +------- -Additional information ----------------------- +.. code-block:: python -* Before using the webhook provided by this library, make sure that you have specified port open. -* Optimal values for port are between 1024 and 49151. -* If you happen to need help implementing topggpy in your bot, feel free to ask in the ``#development`` or ``#api`` channels in our `Discord server `_. + # import the module + import topstats + + import asyncio + import os + + async def main() -> None: + # declare the client. to retrieve your topstats.gg token, see https://docs.topstats.gg/authentication/tokens/ + async with topstats.Client('your topstats.gg API token') as ts: + # fetch a ranked bot from its bot ID + bot = await ts.get_bot(432610292342587392) + + print(bot) + + # fetch topstats.gg's top bots + bots = await ts.get_top_bots(sort_by=topstats.SortBy.server_count()) + + for b in bots: + print(b) + + # compare two bots' historical server count + vs = await ts.compare_bot_server_count(432610292342587392, 437808476106784770) + + for first, second in vs: + print(first, second) + + # compare up to four bots' historical total votes + vs2 = await ts.compare_bot_total_votes( + topstats.Period.LAST_YEAR, + 339254240012664832, + 432610292342587392, + 408785106942164992, + 437808476106784770 + ) + + for first, second, third, fourth in vs2: + print(first, second, third, fourth) + + if __name__ == '__main__': + # see https://stackoverflow.com/questions/45600579/asyncio-event-loop-is-closed-when-getting-loop + # for more details + if os.name == 'nt': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + asyncio.run(main()) .. toctree:: - :maxdepth: 2 - :hidden: - - api/index.rst - examples/index.rst - whats-new - support-server - repository - raw-api-reference \ No newline at end of file + :maxdepth: 2 + :hidden: + + client + data + support-server + repository + raw-api-reference \ No newline at end of file diff --git a/docs/make.bat b/docs/make.bat index 4fbe60d3..954237b9 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -1,36 +1,35 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build -set SPHINXPROJ=topggpy - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% - -:end -popd +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/requirements-docs.txt b/docs/requirements.txt similarity index 60% rename from requirements-docs.txt rename to docs/requirements.txt index 8315d05b..63a65e99 100644 --- a/requirements-docs.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ +aiohttp furo -sphinx +pyyaml sphinx-reredirects \ No newline at end of file diff --git a/docs/support-server.rst b/docs/support-server.rst index 531270f0..008c5a1d 100644 --- a/docs/support-server.rst +++ b/docs/support-server.rst @@ -2,4 +2,4 @@ Support server ============== -You should be redirected in a few moments. Otherwise, click here: https://discord.gg/dbl \ No newline at end of file +You should be redirected in a few moments. Otherwise, click here: https://discord.com/invite/dbl \ No newline at end of file diff --git a/docs/topgg.svg b/docs/topgg.svg deleted file mode 100644 index 63f58125..00000000 --- a/docs/topgg.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/docs/whats-new.rst b/docs/whats-new.rst deleted file mode 100644 index 93082ee5..00000000 --- a/docs/whats-new.rst +++ /dev/null @@ -1,185 +0,0 @@ -########## -What's new -########## - -This page keeps a detailed human friendly rendering of what's new and changed in specific versions. - -v2.0.1 -====== -* Added Python 3.12 support (:issue:`78`) -* Dropped Python 3.6, 3.7, and 3.8 support (:issue:`75`) -* Removed the need to manually set a ``default_bot_id`` property -* Posting shard-related data is now deprecated -* :attr:`.BotData.certified_bot` is now deprecated -* :attr:`.BotData.def_avatar` is now deprecated -* :attr:`.BotData.discriminator` is now deprecated -* :attr:`.BotData.lib` is now deprecated -* :attr:`.BotData.guilds` is now deprecated -* :attr:`.BotStatsData.shards` is now deprecated -* :attr:`.BotStatsData.shard_count` is now deprecated -* :attr:`.UserData.certified_dev` is now deprecated -* :attr:`.UserData.discriminator` is now deprecated -* :meth:`.DBLClient.get_guild_count` no longer accepts a ``bot_id`` argument -* :meth:`.DBLClient.get_bot_votes` no longer raises a :class:`.ClientException` without a ``default_bot_id`` property -* :meth:`.DBLClient.get_bot_info` no longer raises a :class:`.ClientException` without a ``default_bot_id`` property -* :meth:`.DBLClient.generate_widget` no longer raises a :class:`.ClientException` without a ``default_bot_id`` property -* Documentation overhaul - -v2.0.0a -======= -* :obj:`.DBLClient` now doesn't take in ``discord.Client`` instance -* Introduced new `autopost `__ and `data injection `__ API -* `Webhook `__ API breaking changes -* No longer depends on any Discord API wrapper -* :obj:`.GuildVoteData` alias - -v1.4.0 -====== - -* The type of data passed to ``on_dbl_vote`` has been changed from :class:`.dict` to :obj:`BotVoteData` -* The type of data passed to ``on_dsl_vote`` has been changed from :class:`.dict` to :obj:`ServerVoteData` - -v1.3.0 -====== - -* Introduced `global ratelimiter `__ to follow Top.gg global ratelimits - - * Fixed an :exc:`AttributeError` raised by :meth:`.HTTPClient.request` - - * `Resource-specific ratelimit `__ is now actually resource-specific - -v1.2.0 -====== - -* Introduced global ratelimiter along with bot endpoints ratelimiter -* Follow consistency with typing in :class:`.HTTPClient` and :class:`.DBLClient` along with updated docstrings (:issue:`55`) - -v1.1.0 -====== - -* Introduced `data models `__ - - * :meth:`.DBLClient.get_bot_votes` now returns a list of :class:`.BriefUserData` objects - - * :meth:`.DBLClient.get_bot_info` now returns a :class:`.BotData` object - - * :meth:`.DBLClient.get_guild_count` now returns a :class:`.BotStatsData` object - - * :meth:`.DBLClient.get_user_info` now returns a :class:`.UserData` object - -* :meth:`.WebhookManager.run` now returns an :class:`.asyncio.Task`, meaning it can now be optionally awaited - -v1.0.1 -====== - -* :attr:`.WebhookManager.webserver` now instead returns :class:`.aiohttp.web.Application` for ease of use - -v1.0.0 -====== - -* Renamed the module folder from ``dbl`` to ``topgg`` -* Added ``post_shard_count`` argument to :meth:`.DBLClient.post_guild_count` -* Autopost now supports automatic shard posting (:issue:`42`) -* Large webhook system rework, read the :obj:`api/webhook` section for more - - * Added support for server webhooks - -* Renamed ``DBLException`` to :class:`.TopGGException` -* Renamed ``DBLClient.get_bot_upvotes()`` to :meth:`.DBLClient.get_bot_votes` -* Added :meth:`.DBLClient.generate_widget` along with the ``widgets`` section in the documentation -* Implemented a properly working ratelimiter -* Added :func:`on_autopost_error` -* All autopost events now follow ``on_autopost_x`` naming format, e.g. :func:`on_autopost_error`, :func:`on_autopost_success` -* Added handlers for autopost args set when autopost is disabled - -v0.4.0 -====== - -* :meth:`.DBLClient.post_guild_count` now supports a custom ``guild_count`` argument, which accepts either an integer or list of integers -* Reworked how shard info is posted -* Removed ``InvalidArgument`` and ``ConnectionClosed`` exceptions -* Added ``ServerError`` exception - -v0.3.3 -====== - -* Internal changes regarding support of Top.gg migration -* Fixed errors raised when using :meth:`.DBLClient.close` without built-in webhook - -v0.3.2 -====== - -* ``Client`` class has been renamed to :class:`.DBLClient` - -v0.3.1 -====== - -* Added ``on_guild_post``, an event that is called when autoposter successfully posts guild count -* Renamed ``get_upvote_info`` to ``get_bot_upvotes`` -* Added ``get_user_vote`` - -v0.3.0 -====== - -* :class:`.DBLClient` now has ``autopost`` kwarg that will post server count automatically every 30 minutes -* Fixed code 403 errors -* Added ``on_dbl_vote``, an event that is called when you test your webhook -* Added ``on_dbl_test``, an event that is called when someone tests your webhook - -v0.2.1 -====== - -* Added webhook -* Removed support for discord.py versions lower than 1.0.0 -* Made :meth:`.DBLClient.get_weekend_status` return a boolean value -* Added webhook example in README -* Removed ``post_server_count`` and ``get_server_count`` - -v0.2.0 -====== - -* Added ``post_guild_count`` - - * Made ``post_server_count`` an alias for ``post_guild_count`` - - * Added ``get_guild_count`` - -* Made ``get_server_count`` an alias for ``get_guild_count`` - -* Added :meth:`.DBLClient.get_weekend_status` -* Removed all parameters from :meth:`.DBLClient.get_upvote_info` -* Added limit to :meth:`.DBLClient.get_bots` -* Fixed example in README - -v0.1.6 -====== - -* Bug fixes & improvements - -v0.1.4 -====== - -* Initial ratelimit handling - -v0.1.3 -====== - -* Added documentation -* Fixed some minor bugs - -v0.1.2 -====== - -Initial release - -* Working - - * POSTing server count - * GET bot info, server count, upvote count, upvote info - * GET all bots - * GET specific user info - * GET widgets (large and small) including custom ones. See `Top.gg docs `_ for more info. - -* Not Working / Implemented - - * Searching for bots via the api diff --git a/examples/discordpy_example/__main__.py b/examples/discordpy_example/__main__.py deleted file mode 100644 index 9ae6595b..00000000 --- a/examples/discordpy_example/__main__.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021 Norizon -Copyright (c) 2024-2025 null8626 - -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 - -from .callbacks import autopost, webhook - -client = discord.Client() -webhook_manager = topgg.WebhookManager().set_data(client).endpoint(webhook.endpoint) -dblclient = topgg.DBLClient("TOPGG_TOKEN").set_data(client) -autoposter: topgg.AutoPoster = ( - dblclient.autopost() - .on_success(autopost.on_autopost_success) - .on_error(autopost.on_autopost_error) - .stats(autopost.stats) -) - - -@client.event -async def on_ready(): - assert client.user is not None - - # if it's ready, then the event loop's run, - # hence it's safe starting the autopost here - if not autoposter.is_running: - # don't await unless you want to wait for the autopost loop to get finished - autoposter.start() - - # we can also start the webhook here - if not webhook_manager.is_running: - await webhook_manager.start(6000) - - -# 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") diff --git a/examples/discordpy_example/callbacks/autopost.py b/examples/discordpy_example/callbacks/autopost.py deleted file mode 100644 index 2ca58b78..00000000 --- a/examples/discordpy_example/callbacks/autopost.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -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 -import topgg - - -# these functions can be async too! -def on_autopost_success( - # uncomment this if you want to get access to client - # client: discord.Client = topgg.data(discord.Client) -): - # will be called whenever it successfully posting - print("Successfully posted!") - - # do whatever with client - # you can dispatch your own event for more callbacks - # client.dispatch("autopost_success") - - -def on_autopost_error( - exception: Exception, - # uncomment this if you want to get access to client - # client: discord.Client = topgg.data(discord.Client), -): - # will be called whenever it failed posting - print("Failed to post:", exception, file=sys.stderr) - - # do whatever with client - # you can dispatch your own event for more callbacks - # client.dispatch("autopost_error", exception) - - -def stats(client: discord.Client = topgg.data(discord.Client)): - return topgg.StatsWrapper(guild_count=len(client.guilds)) diff --git a/examples/discordpy_example/callbacks/webhook.py b/examples/discordpy_example/callbacks/webhook.py deleted file mode 100644 index 2207547c..00000000 --- a/examples/discordpy_example/callbacks/webhook.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -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 - - -# this can be async too! -@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) - - # do anything with client here - # client.dispatch("dbl_vote", vote_data) diff --git a/examples/discordpy_example/requirements.txt b/examples/discordpy_example/requirements.txt deleted file mode 100644 index c52e65f2..00000000 --- a/examples/discordpy_example/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -discord.py -topggpy \ No newline at end of file diff --git a/examples/hikari_example/__main__.py b/examples/hikari_example/__main__.py deleted file mode 100644 index f23b6b4c..00000000 --- a/examples/hikari_example/__main__.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021 Norizon -Copyright (c) 2024-2025 null8626 - -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 - -from .callbacks import autopost, webhook - -app = hikari.GatewayBot("TOKEN") -webhook_manager = topgg.WebhookManager().set_data(app).endpoint(webhook.endpoint) -dblclient = topgg.DBLClient("TOPGG_TOKEN").set_data(app) -autoposter: topgg.AutoPoster = ( - dblclient.autopost() - .on_success(autopost.on_autopost_success) - .on_error(autopost.on_autopost_error) - .stats(autopost.stats) -) - - -@app.listen() -async def on_started(event: hikari.StartedEvent): - me: hikari.OwnUser = event.app.get_me() - assert me is not None - - # since StartedEvent is a lifetime event - # this event will only get dispatched once - autoposter.start() - await webhook_manager.start(6000) - - -@app.listen() -async def on_stopping(_: hikari.StoppingEvent): - await dblclient.close() - await webhook_manager.close() - - -app.run() diff --git a/examples/hikari_example/callbacks/autopost.py b/examples/hikari_example/callbacks/autopost.py deleted file mode 100644 index e09a0165..00000000 --- a/examples/hikari_example/callbacks/autopost.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -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 -import topgg - -# from ..events.autopost import AutoPostErrorEvent, AutoPostSuccessEvent - -_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 - # app: hikari.GatewayBot = topgg.data(hikari.GatewayBot), -): - # will be called whenever it successfully posting - _LOGGER.info("Successfully posted!") - - # do whatever with app - # you can dispatch your own event for more callbacks - # app.dispatch(AutoPostSuccessEvent(app=app)) - - -def on_autopost_error( - exception: Exception, - # uncomment this if you want to get access to app - # app: hikari.GatewayBot = topgg.data(hikari.GatewayBot), -): - # will be called whenever it failed posting - _LOGGER.error("Failed to post...", exc_info=exception) - - # do whatever with app - # you can dispatch your own event for more callbacks - # app.dispatch(AutoPostErrorEvent(app=app, exception=exception)) - - -def stats(app: hikari.GatewayBot = topgg.data(hikari.GatewayBot)): - return topgg.StatsWrapper(guild_count=len(app.cache.get_guilds_view())) diff --git a/examples/hikari_example/callbacks/webhook.py b/examples/hikari_example/callbacks/webhook.py deleted file mode 100644 index 222f9445..00000000 --- a/examples/hikari_example/callbacks/webhook.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -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 topgg -# import hikari - - -# from ..events import BotUpvoteEvent - -_LOGGER = logging.getLogger("callbacks.webhook") - - -# this can be async too! -@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) - # do anything with app here. - # app.dispatch(BotUpvoteEvent(app=app, data=vote_data)) diff --git a/examples/hikari_example/events/autopost.py b/examples/hikari_example/events/autopost.py deleted file mode 100644 index 02e9967d..00000000 --- a/examples/hikari_example/events/autopost.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -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 - - -@attr.define(kw_only=True, weakref_slot=False) -class AutoPostSuccessEvent(hikari.Event): - app: hikari.GatewayBot - - -@attr.define(kw_only=True, weakref_slot=False) -class AutoPostErrorEvent(hikari.Event): - app: hikari.GatewayBot - exception: Exception diff --git a/examples/hikari_example/events/webhook.py b/examples/hikari_example/events/webhook.py deleted file mode 100644 index 8183b742..00000000 --- a/examples/hikari_example/events/webhook.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -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 -import topgg - - -@attr.define(kw_only=True, weakref_slot=False) -class BotUpvoteEvent(hikari.Event): - app: hikari.GatewayBot - data: topgg.BotVoteData diff --git a/examples/hikari_example/requirements.txt b/examples/hikari_example/requirements.txt deleted file mode 100644 index 974cd430..00000000 --- a/examples/hikari_example/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -hikari -topggpy \ 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 5534328b..bdbf700b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ requires = ["setuptools"] [project] name = "topggpy" -version = "2.0.1" +version = "3.0.0" description = "A simple API wrapper for Top.gg written in Python." -readme = "README.rst" +readme = "README.md" license = { text = "MIT" } authors = [{ name = "null8626" }, { name = "Top.gg" }] keywords = ["discord", "discord-bot", "topgg"] @@ -32,6 +32,5 @@ requires-python = ">=3.9" [project.urls] Documentation = "https://topggpy.readthedocs.io/en/latest/" "Raw API Documentation" = "https://docs.top.gg/docs/" -"Release notes" = "https://topggpy.readthedocs.io/en/latest/whats_new.html" Repository = "https://github.com/top-gg-community/python-sdk" "Support server" = "https://discord.gg/dbl" \ No newline at end of file diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 83633087..00000000 --- a/pytest.ini +++ /dev/null @@ -1,4 +0,0 @@ -[pytest] -xfail_strict = true -norecursedirs = docs *.egg-info .git -addopts = --cov=topgg \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 36ad3056..00000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Formatting and Linting -ruff - -# Unit Testing -mock -pytest -pytest-asyncio -pytest-mock -pytest-cov \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 4143b58b..00000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -aiohttp>=3.11.12 diff --git a/ruff.toml b/ruff.toml index 650555f8..ac0f30da 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,9 +1,10 @@ -line-length = 120 +indent-width = 2 [format] docstring-code-format = true -docstring-code-line-length = 120 +docstring-code-line-length = 88 line-ending = "lf" +quote-style = "single" [lint] ignore = ["E722", "F401", "F403"] \ No newline at end of file diff --git a/tests/test_autopost.py b/tests/test_autopost.py deleted file mode 100644 index 115297cc..00000000 --- a/tests/test_autopost.py +++ /dev/null @@ -1,87 +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 ServerError, TopGGException, Unauthorized - - -MOCK_TOKEN = "amogus.eyJpZCI6IjEwMjY1MjU1NjgzNDQyNjQ3MjQiLCJib3QiOnRydWUsImlhdCI6MTY5OTk4NDYyM30.amogus" - - -@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=Unauthorized(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(Unauthorized): - 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 = ServerError(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 deleted file mode 100644 index b74acea6..00000000 --- a/tests/test_client.py +++ /dev/null @@ -1,106 +0,0 @@ -import mock -import pytest -from aiohttp import ClientSession - -import topgg -from topgg import errors - - -MOCK_TOKEN = "amogus.eyJpZCI6IjEwMjY1MjU1NjgzNDQyNjQ3MjQiLCJib3QiOnRydWUsImlhdCI6MTY5OTk4NDYyM30.amogus" - - -@pytest.fixture -def session() -> ClientSession: - return mock.Mock(ClientSession) - - -@pytest.fixture -def client() -> topgg.DBLClient: - client = topgg.DBLClient(token=MOCK_TOKEN) - client.http = mock.Mock(topgg.http.HTTPClient) - return client - - -@pytest.mark.asyncio -async def test_HTTPClient_with_external_session(session: ClientSession): - http = topgg.http.HTTPClient(MOCK_TOKEN, session=session) - assert not http._own_session - await http.close() - session.close.assert_not_called() - - -@pytest.mark.asyncio -async def test_HTTPClient_with_no_external_session(session: ClientSession): - http = topgg.http.HTTPClient(MOCK_TOKEN) - http.session = session - assert http._own_session - await http.close() - session.close.assert_called_once() - - -@pytest.mark.asyncio -async def test_DBLClient_post_guild_count_with_no_args(): - client = topgg.DBLClient(MOCK_TOKEN) - with pytest.raises(TypeError, match="guild_count must be provided."): - await client.post_guild_count() - - -@pytest.mark.asyncio -async def test_closed_DBLClient_raises_exception(): - client = topgg.DBLClient(MOCK_TOKEN) - assert not client.is_closed - await client.close() - assert client.is_closed - with pytest.raises(errors.ClientException, match="client has been closed."): - await client.get_weekend_status() - - -@pytest.mark.asyncio -async def test_DBLClient_bot_id(): - client = topgg.DBLClient(MOCK_TOKEN) - assert not client.is_closed - assert client.bot_id == 1026525568344264724 - await client.close() - assert client.is_closed - - -@pytest.mark.asyncio -async def test_DBLClient_get_weekend_status(client: topgg.DBLClient): - client.http.get_weekend_status = mock.AsyncMock() - await client.get_weekend_status() - client.http.get_weekend_status.assert_called_once() - - -@pytest.mark.asyncio -async def test_DBLClient_post_guild_count(client: topgg.DBLClient): - client.http.post_guild_count = mock.AsyncMock() - await client.post_guild_count(guild_count=123) - client.http.post_guild_count.assert_called_once() - - -@pytest.mark.asyncio -async def test_DBLClient_get_guild_count(client: topgg.DBLClient): - client.http.get_guild_count = mock.AsyncMock(return_value={}) - await client.get_guild_count() - client.http.get_guild_count.assert_called_once() - - -@pytest.mark.asyncio -async def test_DBLClient_get_bot_votes(client: topgg.DBLClient): - client.http.get_bot_votes = mock.AsyncMock(return_value=[]) - await client.get_bot_votes() - client.http.get_bot_votes.assert_called_once() - - -@pytest.mark.asyncio -async def test_DBLClient_get_user_info(client: topgg.DBLClient): - client.http.get_user_info = mock.AsyncMock(return_value={}) - await client.get_user_info(1234) - client.http.get_user_info.assert_called_once() - - -@pytest.mark.asyncio -async def test_DBLClient_get_user_vote(client: topgg.DBLClient): - client.http.get_user_vote = mock.AsyncMock(return_value={"voted": 1}) - await client.get_user_vote(1234) - client.http.get_user_vote.assert_called_once() diff --git a/tests/test_data_container.py b/tests/test_data_container.py deleted file mode 100644 index f89466ea..00000000 --- a/tests/test_data_container.py +++ /dev/null @@ -1,52 +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 deleted file mode 100644 index 9153b3a1..00000000 --- a/tests/test_ratelimiter.py +++ /dev/null @@ -1,28 +0,0 @@ -import pytest - -from topgg.ratelimiter import AsyncRateLimiter - -n = period = 10 - - -@pytest.fixture -def limiter() -> AsyncRateLimiter: - return AsyncRateLimiter(max_calls=n, period=period) - - -@pytest.mark.asyncio -async def test_AsyncRateLimiter_calls(limiter: AsyncRateLimiter) -> None: - for _ in range(n): - async with limiter: - pass - - assert len(limiter.calls) == limiter.max_calls == n - - -@pytest.mark.asyncio -async def test_AsyncRateLimiter_timespan_property(limiter: AsyncRateLimiter) -> 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 deleted file mode 100644 index f53abf6c..00000000 --- a/tests/test_type.py +++ /dev/null @@ -1,183 +0,0 @@ -import pytest - -from topgg import types - -d: dict = { - "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": "", -} - -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", -} - -bot_vote_dict = { - "bot": "2", - "user": "3", - "type": "test", - "query": "?" + "&".join(f"{k}={v}" for k, v in query_dict.items()), -} - -server_vote_dict = { - "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) - - -@pytest.fixture -def bot_data() -> types.BotData: - 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"])) - - -@pytest.fixture -def vote_data() -> types.VoteDataDict: - return types.VoteDataDict(**vote_data_dict) - - -@pytest.fixture -def bot_vote_data() -> types.BotVoteData: - return types.BotVoteData(**bot_vote_dict) - - -@pytest.fixture -def server_vote_data() -> types.GuildVoteData: - 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) - - -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: - if "id" in attr.lower(): - assert isinstance(bot_data[attr], int) or bot_data[attr] is None - elif attr == "owners": - 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) - 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) - 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: - assert isinstance(bot_stats_data["server_count"], int) or bot_stats_data["server_count"] is None - - -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 deleted file mode 100644 index db1da09d..00000000 --- a/tests/test_webhook.py +++ /dev/null @@ -1,76 +0,0 @@ -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() diff --git a/topgg/__init__.py b/topgg/__init__.py index d3e6b2fa..05652dc7 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -1,24 +1,35 @@ """ -Top.gg Python API Wrapper -~~~~~~~~~~~~~~~~~~~~~~~~~ -A basic wrapper for the Top.gg API. -:copyright: (c) 2021 Assanali Mukhanov & Top.gg -:copyright: (c) 2024-2025 null8626 & 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: -__title__ = "topggpy" -__author__ = "null8626" -__license__ = "MIT" -__version__ = "2.0.1" +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 * -from .client import * -from .data import * -from .errors import * -from .http import * +from .client import Client +from .errors import Error, RequestError, Ratelimited +from .models import Bot, BotQuery, Voter -# 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' +__license__ = 'MIT' +__copyright__ = 'Copyright (c) 2021 Assanali Mukhanov & Top.gg; Copyright (c) 2024-2025 null8626 & Top.gg' +__version__ = '3.0.0' +__all__ = ('Bot', 'BotQuery', 'Client', 'Error', 'Ratelimited', 'RequestError', 'Voter') diff --git a/topgg/autopost.py b/topgg/autopost.py deleted file mode 100644 index 28a2abf8..00000000 --- a/topgg/autopost.py +++ /dev/null @@ -1,323 +0,0 @@ -""" -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",) - -import asyncio -import datetime -import sys -import traceback -import typing as t - -from topgg import errors - -from .types import StatsWrapper - -if t.TYPE_CHECKING: - import asyncio - - from .client import DBLClient - -CallbackT = t.Callable[..., t.Any] -StatsCallbackT = t.Callable[[], StatsWrapper] - - -class AutoPoster: - """ - A helper class for autoposting. Takes in a :obj:`~.client.DBLClient` to instantiate. - - Note: - You should not instantiate this unless you know what you're doing. - Generally, you'd better use the :meth:`~.client.DBLClient.autopost` method. - - Args: - client (:obj:`~.client.DBLClient`) - An instance of DBLClient. - """ - - __slots__: t.Tuple[str, ...] = ( - "_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: - super().__init__() - self.client = client - self._interval: float = 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) - traceback.print_exception(type(exception), exception, exception.__traceback__, file=sys.stderr) - - @t.overload - def on_success(self, callback: None) -> t.Callable[[CallbackT], CallbackT]: ... - - @t.overload - def on_success(self, callback: CallbackT) -> "AutoPoster": ... - - def on_success(self, callback: t.Any = None) -> t.Any: - """ - 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 - - # 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 factory, the decorated function will still be the function itself. - @autopost.on_success() - def on_success(): ... - """ - 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]: ... - - @t.overload - def on_error(self, callback: CallbackT) -> "AutoPoster": ... - - def on_error(self, callback: t.Any = None) -> t.Any: - """ - 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`. - 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. - - :Example: - .. code-block:: python - - # The following are valid. - autopost = dblclient.autopost().on_error(lambda exc: print("Failed posting stats!", exc)) - - - # 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): ... - """ - 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]: ... - - @t.overload - def stats(self, callback: StatsCallbackT) -> "AutoPoster": ... - - def stats(self, callback: t.Any = None) -> t.Any: - """ - Registers a function that returns an instance of :obj:`~.types.StatsWrapper`. - - 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 - - import topgg - - # 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)) - - - # somewhere after the event loop has started - autopost.start() - """ - if callback is not None: - self._stats = callback - return self - - def decorator(callback: StatsCallbackT) -> StatsCallbackT: - self._stats = callback - return callback - - return decorator - - @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`.""" - self.set_interval(seconds) - - def set_interval(self, seconds: t.Union[float, datetime.timedelta]) -> "AutoPoster": - """ - Sets the interval between posting stats. - - Args: - seconds (Union[:obj:`float`, :obj:`datetime.timedelta`]) - The interval. - - Raises: - ValueError - If the provided interval is less than 900 seconds. - """ - if isinstance(seconds, datetime.timedelta): - seconds = seconds.total_seconds() - - if seconds < 900: - 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.""" - 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: - self._refresh_state() - if future.cancelled(): - return - future.exception() - - async def _internal_loop(self) -> None: - try: - while True: - 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.Unauthorized): - raise err from None - else: - if on_success := getattr(self, "_success", None): - await self.client._invoke_callback(on_success) - - if self._stopping: - return - - await asyncio.sleep(self.interval) - finally: - self._refresh_state() - - def start(self) -> "asyncio.Task[None]": - """ - Starts the autoposting loop. - - Note: - This method must be called when the event loop has already running! - - Raises: - :exc:`~.errors.TopGGException` - If there's no callback provided or the autopost is already running. - """ - if not hasattr(self, "_stats"): - raise errors.TopGGException("you must provide a callback that returns the stats.") - - if self.is_running: - raise errors.TopGGException("the autopost 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. - - Note: - This differs from :meth:`~.autopost.AutoPoster.cancel` - because this will post once before stopping as opposed to cancel immediately. - """ - if not self.is_running: - return - - self._stopping = True - - def cancel(self) -> None: - """ - Cancels the autoposting loop. - - Note: - This differs from :meth:`~.autopost.AutoPoster.stop` - because this will stop the loop right away. - """ - if self._task is None: - return - - self._task.cancel() - self._refresh_state() diff --git a/topgg/client.py b/topgg/client.py index 3883b7a0..9a1d0201 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -1,295 +1,252 @@ """ The MIT License (MIT) -Copyright (c) 2021 Assanali Mukhanov -Copyright (c) 2024-2025 null8626 +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: +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 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, +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. +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__ = ("DBLClient",) - -import base64 +from aiohttp import ClientSession, ClientTimeout +from typing import Optional, Tuple, List +from collections import namedtuple +from base64 import b64decode +from time import time +import asyncio import json -import typing as t -import aiohttp +from .models import Bot, BotQuery, Voter +from .errors import Error, RequestError, Ratelimited +from .ratelimiter import Ratelimiter, RatelimiterManager + +BASE_URL = 'https://top.gg/api' +MAXIMUM_DELAY_THRESHOLD = 5.0 + + +class Client: + """ + The class that lets you interact with the API. + + :param token: The API token to use with the API. To retrieve your topstats.gg API token, see https://docs.top.gg/docs/API/@reference. + :type token: :py:class:`str` + :param session: Whether to use an existing :class:`~aiohttp.ClientSession` for requesting or not. Defaults to :py:obj:`None` (creates a new one instead) + :type session: Optional[:class:`~aiohttp.ClientSession`] + + :raises TypeError: If ``token`` is not a :py:class:`str`. + :raises ValueError: If ``token`` is not a valid API token. + """ + + __slots__: Tuple[str, ...] = ( + '__own_session', + '__session', + '__token', + '__ratelimiters', + '__ratelimiter_manager', + '__current_ratelimit', + 'id', + ) + + def __init__(self, token: str, *, session: Optional[ClientSession] = None): + if not isinstance(token, str): + raise TypeError('An API token is required to use this API.') + + self.__own_session = session is None + self.__session = session or ClientSession( + timeout=ClientTimeout(total=MAXIMUM_DELAY_THRESHOLD * 1000.0) + ) + self.__token = token + + try: + encoded_json = token.split('.')[1] + encoded_json += '=' * (4 - (len(encoded_json) % 4)) + + self.id = int(json.loads(b64decode(encoded_json))['id']) + except: + raise ValueError('Got a malformed Top.gg API token.') + + endpoint_ratelimits = namedtuple('EndpointRatelimits', '_global bot') + + self.__ratelimiters = endpoint_ratelimits( + _global=Ratelimiter(99, 1), bot=Ratelimiter(59, 60) + ) + self.__ratelimiter_manager = RatelimiterManager(self.__ratelimiters) + self.__current_ratelimit = None + + async def __request( + self, + method: str, + path: str, + params: dict = {}, + json: Optional[dict] = None, + treat_404_as_none: bool = True, + ) -> Optional[dict]: + if self.__session.closed: + raise Error('Client session is already closed.') + + if self.__current_ratelimit is not None: + current_time = time() + + if current_time < self.__current_ratelimit: + raise Ratelimited(self.__current_ratelimit - current_time) + else: + self.__current_ratelimit = None + + ratelimiter = ( + self.__ratelimiter_manager + if path.startswith('/bots') + else self.__ratelimiters._global + ) + + status = None + headers = None + json = {} + + async with ratelimiter: + try: + async with self.__session.request( + method, + BASE_URL + path, + headers={ + 'Authorization': self.__token, + 'Content-Type': 'application/json', + 'User-Agent': 'topggpy (https://github.com/top-gg-community/python-sdk 3.0.0) Python/', + }, + json=json, + params=params, + ) as resp: + status = resp.status + headers = resp.headers + json = await resp.json() + + assert 200 <= status <= 299 + return json + except: + if status == 404 and treat_404_as_none: + return + elif status == 429: + retry_after = float(headers['Retry-After']) + + if retry_after > MAXIMUM_DELAY_THRESHOLD: + self.__current_ratelimit = time() + retry_after + + raise Ratelimited(retry_after) from None + else: + await asyncio.sleep(retry_after) + else: + raise RequestError(json, status) from None + + return await self.__request(method, path) + + async def get_bot(self, id: int) -> Optional[Bot]: + """ + Fetches a Discord bot from its Discord ID. + + :param id: The requested Discord ID. + :type id: :py:class:`int` + + :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. + :exception RequestError: If the client received a non-favorable response from the API. + :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. + + :returns: The requested Discord bot. This can be :py:obj:`None` if the requested Discord bot does not exist. + :rtype: Optional[:class:`~.models.Bot`] + """ + + bot = await self.__request('GET', f'/bots/{id}') + + return bot and Bot(bot) + + def get_bots(self) -> BotQuery: + """ + Fetches a list of Discord bots that matches the specified bot query. + + :returns: A :class:`~.models.BotQuery` object, which allows you to configure a Discord bot query before sending it to the API to retrieve a list of Discord bots that matches the specified query. + :rtype: :class:`~.models.BotQuery` + """ + + return BotQuery(self) + + async def is_weekend(self) -> bool: + """ + Checks if the weekend multiplier is active. + + :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. + :exception RequestError: If the client received a non-favorable response from the API. + :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. + + :returns: Whether the weekend multiplier is active or not. + :rtype: bool + """ + + response = await self.__request('GET', '/weekend', treat_404_as_none=False) -from . import errors, types -from .autopost import AutoPoster -from .data import DataContainerMixin -from .http import HTTPClient + return response['is_weekend'] + async def get_voters(self) -> List[Voter]: + """ + Fetches your Discord bot's last 1000 voters. + + :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. + :exception RequestError: If the client received a non-favorable response from the API. + :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. -class DBLClient(DataContainerMixin): - """Represents a client connection that connects to Top.gg. + :returns: Your Discord bot's last 1000 voters. + :rtype: List[:class:`~.models.Voter`] + """ - This class is used to interact with the Top.gg API. + voters = await self.__request('GET', f'/bots/{self.id}/votes') + output = [] - .. _aiohttp session: https://aiohttp.readthedocs.io/en/stable/client_reference.html#client-session + for voter in voters or (): + output.append(Voter(voter)) - Args: - token (:obj:`str`): Your bot's Top.gg API Token. + return output - Keyword Args: - session (:class:`~aiohttp.ClientSession`) - An `aiohttp session`_ to use for requests to the API. - **kwargs: - Arbitrary kwargs to be passed to :class:`~aiohttp.ClientSession` if session was not provided. + async def has_voted(self, id: int) -> bool: """ + Checks if the specified user has voted your Discord bot. + + :param id: The requested user's Discord ID. + :type id: :py:class:`int` + + :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. + :exception RequestError: If the client received a non-favorable response from the API. + :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. + + :returns: Whether the specified user has voted your Discord bot. + :rtype: bool + """ + + response = await self.__request( + 'GET', f'/bots/{self.id}/check?userId={id}', treat_404_as_none=False + ) + + return bool(response['voted']) + + async def close(self) -> None: + """Closes the :class:`~.client.Client` object. Nothing will happen if the client uses a pre-existing :class:`~aiohttp.ClientSession` or if the session is already closed.""" + + if self.__own_session and not self.__session.closed: + await self.__session.close() - __slots__: t.Tuple[str, ...] = ("http", "bot_id", "_token", "_is_closed", "_autopost") - - http: HTTPClient - - def __init__( - self, - token: str, - *, - session: t.Optional[aiohttp.ClientSession] = None, - **kwargs: t.Any, - ) -> None: - super().__init__() - self._token = token - - try: - encoded_json = token.split(".")[1] - encoded_json += "=" * (4 - (len(encoded_json) % 4)) - - self.bot_id = int(json.loads(base64.b64decode(encoded_json))["id"]) - except: - raise errors.ClientException("invalid token.") - - self._is_closed = False - if session is not None: - self.http = HTTPClient(token, session=session) - self._autopost: t.Optional[AutoPoster] = None - - @property - def is_closed(self) -> bool: - return self._is_closed - - async def _ensure_session(self) -> None: - if self.is_closed: - raise errors.ClientStateException("client has been closed.") - - if not hasattr(self, "http"): - self.http = HTTPClient(self._token, session=None) - - async def get_weekend_status(self) -> bool: - """Gets weekend status from Top.gg. - - Returns: - :obj:`bool`: The boolean value of weekend status. - - Raises: - :exc:`~.errors.ClientStateException` - If the client has been closed. - """ - await self._ensure_session() - data = await self.http.get_weekend_status() - return data["is_weekend"] - - async def post_guild_count(self, guild_count: t.Optional[int] = None) -> None: - """Posts your bot's guild count to Top.gg. - - Args: - guild_count (Optional[:obj:`int`]) - Number of guilds the bot is in. - If not specified, length of provided client's property `.guilds` will be posted. - - Raises: - TypeError - If no argument is provided. - :exc:`~.errors.ClientStateException` - If the client has been closed. - """ - if guild_count is None: - raise TypeError("guild_count must be provided.") - - await self._ensure_session() - await self.http.post_guild_count(guild_count) - - async def get_guild_count(self) -> types.BotStatsData: - """Gets this bot's guild count from Top.gg. - - Returns: - :obj:`~.types.BotStatsData`: - The guild count on Top.gg. - - Raises: - :exc:`~.errors.ClientStateException` - If the client has been closed. - """ - await self._ensure_session() - response = await self.http.get_guild_count(self.bot_id) - return types.BotStatsData(**response) - - async def get_bot_votes(self) -> t.List[types.BriefUserData]: - """Gets information about last 1000 votes for your bot on Top.gg. - - Note: - This API endpoint is only available to the bot's owner. - - Returns: - List[:obj:`~.types.BriefUserData`]: - Users who voted for your bot. - - Raises: - :exc:`~.errors.ClientStateException` - If the client has been closed. - """ - await self._ensure_session() - response = await self.http.get_bot_votes(self.bot_id) - return [types.BriefUserData(**user) for user in response] - - async def get_bot_info(self, bot_id: t.Optional[int] = None) -> types.BotData: - """This function is a coroutine. - - Gets information about a bot from Top.gg. - - Args: - bot_id (int) - ID of the bot to look up. Defaults to this bot's ID. - - Returns: - :obj:`~.types.BotData`: - Information on the bot you looked up. Returned data can be found - `here `_. - - Raises: - :exc:`~.errors.ClientStateException` - If the client has been closed. - """ - await self._ensure_session() - response = await self.http.get_bot_info(bot_id or self.bot_id) - return types.BotData(**response) - - async def get_bots( - self, - limit: int = 50, - offset: int = 0, - sort: t.Optional[str] = None, - search: t.Optional[t.Dict[str, t.Any]] = None, - fields: t.Optional[t.List[str]] = None, - ) -> types.DataDict[str, t.Any]: - sort = sort or "" - search = search or {} - fields = fields or [] - await self._ensure_session() - response = await self.http.get_bots(limit, offset, sort, search, fields) - response["results"] = [types.BotData(**bot_data) for bot_data in response["results"]] - return types.DataDict(**response) - - async def get_user_info(self, user_id: int) -> types.UserData: - """This function is a coroutine. - - Gets information about a user on Top.gg. - - Args: - user_id (int) - ID of the user to look up. - - Returns: - :obj:`~.types.UserData`: - Information about a Top.gg user. - - Raises: - :exc:`~.errors.ClientStateException` - If the client has been closed. - """ - await self._ensure_session() - response = await self.http.get_user_info(user_id) - return types.UserData(**response) - - async def get_user_vote(self, user_id: int) -> bool: - """Gets information about a user's vote for your bot on Top.gg. - - Args: - user_id (int) - ID of the user. - - Returns: - :obj:`bool`: Info about the user's vote. - - Raises: - :exc:`~.errors.ClientStateException` - If the client has been closed. - """ - await self._ensure_session() - data = await self.http.get_user_vote(self.bot_id, user_id) - return bool(data["voted"]) - - def generate_widget(self, *, options: types.WidgetOptions) -> str: - """ - Generates a Top.gg widget from the provided :obj:`~.types.WidgetOptions` object. - - Keyword Arguments: - options (:obj:`~.types.WidgetOptions`) - A :obj:`~.types.WidgetOptions` object containing widget parameters. - - Returns: - str: Generated widget URL. - - Raises: - TypeError: - If options passed is not of type WidgetOptions. - """ - if not isinstance(options, types.WidgetOptions): - raise TypeError("options argument passed to generate_widget must be of type WidgetOptions") - - bot_id = options.id or self.bot_id - widget_query = f"noavatar={str(options.noavatar).lower()}" - - for key, value in options.colors.items(): - widget_query += f"&{key.lower()}{'' if key.lower().endswith('color') else 'color'}={value:x}" - - widget_format = options.format - widget_type = f"/{options.type}" if options.type else "" - - return f"https://top.gg/api/widget{widget_type}/{bot_id}.{widget_format}?{widget_query}" - - async def close(self) -> None: - """Closes all connections.""" - if self.is_closed: - return - - if hasattr(self, "http"): - await self.http.close() - - if self._autopost: - self._autopost.cancel() - - self._is_closed = True - - def autopost(self) -> AutoPoster: - """Returns a helper instance for auto-posting. - - Note: - The second time you call this method, it'll return the same instance - as the one returned from the first call. - - Returns: - :obj:`~.autopost.AutoPoster`: An instance of AutoPoster. - """ - if self._autopost is not None: - return self._autopost + async def __aenter__(self) -> 'Client': + return self - self._autopost = AutoPoster(self) - return self._autopost + async def __aexit__(self, *_, **__) -> None: + await self.close() diff --git a/topgg/data.py b/topgg/data.py deleted file mode 100644 index b21d0a4c..00000000 --- a/topgg/data.py +++ /dev/null @@ -1,144 +0,0 @@ -""" -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", -) - -import inspect -import typing as t - -from topgg.errors import TopGGException - -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. - - Args: - `type_` (:obj:`type` [ :obj:`T`]) - The type of the injected data. - - Returns: - :obj:`T`: The injected data of type T. - - :Example: - .. code-block:: python - - import topgg - - # 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() - - - @autopost.stats() - def get_stats(client: Client = topgg.data(Client)): - return topgg.StatsWrapper(guild_count=len(client.guilds)) - """ - return t.cast(T, Data(type_)) - - -class Data(t.Generic[T]): - __slots__: t.Tuple[str, ...] = ("type",) - - def __init__(self, type_: t.Type[T]) -> None: - self.type: t.Type[T] = type_ - - -class DataContainerMixin: - """ - A class that holds data. - - This is useful for injecting some data so that they're available - as arguments in your functions. - """ - - __slots__: t.Tuple[str, ...] = ("_data",) - - def __init__(self) -> None: - self._data: t.Dict[t.Type, t.Any] = {type(self): self} - - def set_data(self: DataContainerT, data_: t.Any, *, override: bool = False) -> DataContainerT: - """ - Sets data to be available in your functions. - - Args: - `data_` (Any) - The data to be injected. - override (:obj:`bool`) - Whether or not to override another instance that already exists. - - Raises: - :exc:`~.errors.TopGGException` - If override is False and another instance of the same type exists. - """ - 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." - ) - - self._data[type_] = data_ - return self - - @t.overload - 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.Any, default: t.Any = None) -> t.Any: - """Gets the injected data.""" - 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] = { - k: v.default - for k, v in parameters.items() - if v.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD and isinstance(v.default, Data) - } - - for k, v in signatures.items(): - signatures[k] = self._resolve_data(v.type) - - res = callback(*args, **{**signatures, **kwargs}) - if inspect.isawaitable(res): - return await res - - return res - - def _resolve_data(self, type_: t.Type[T]) -> T: - return self._data[type_] diff --git a/topgg/errors.py b/topgg/errors.py index e15d39be..b08757f9 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -1,103 +1,72 @@ """ The MIT License (MIT) -Copyright (c) 2021 Assanali Mukhanov +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: +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 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, +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. +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__ = ( - "TopGGException", - "ClientException", - "ClientStateException", - "HTTPException", - "Unauthorized", - "UnauthorizedDetected", - "Forbidden", - "NotFound", - "ServerError", -) +from typing import Tuple, Optional -from typing import TYPE_CHECKING, Union -if TYPE_CHECKING: - from aiohttp import ClientResponse +class Error(Exception): + """The base error class. Extends :py:class:`Exception`.""" + __slots__: Tuple[str, ...] = () -class TopGGException(Exception): - """Base exception class for topggpy. - Ideally speaking, this could be caught to handle any exceptions thrown from this library. - """ +class RequestError(Error): + """Thrown upon HTTP request failure. Extends :class:`~.errors.Error`.""" + __slots__: Tuple[str, ...] = ('message', 'status') -class ClientException(TopGGException): - """Exception that's thrown when an operation in the :class:`.DBLClient` fails. + message: Optional[str] + """The message returned from the API. This can be :py:obj:`None`.""" - These are usually for exceptions that happened due to user input. - """ + status: Optional[int] + """The status code returned from the API. This can be :py:obj:`None`.""" + def __init__(self, json: dict, status: Optional[int]): + self.message = json.get('message') + self.status = status -class ClientStateException(ClientException): - """Exception that's thrown when an operation happens in a closed :obj:`.DBLClient` instance.""" + super().__init__(f'Got {status}: {self.message!r}') + def __repr__(self) -> str: + return f'<{__class__.__name__} message={self.message!r} status={self.status}>' -class HTTPException(TopGGException): - """Exception that's thrown when an HTTP request operation fails. - Attributes: - response (:class:`aiohttp.ClientResponse`) - The response of the failed HTTP request. - text (str) - The text of the error. Could be an empty string. - """ +class Ratelimited(Error): + """Thrown upon HTTP request failure due to the client being ratelimited. Because of this, the client is not allowed to make requests for an hour. Extends :class:`~.errors.Error`.""" - def __init__(self, response: "ClientResponse", message: Union[dict, str]) -> None: - self.response = response - if isinstance(message, dict): - self.text = message.get("message", "") - self.code = message.get("code", 0) - else: - self.text = message + __slots__: Tuple[str, ...] = ('retry_after',) - fmt = f"{self.response.reason} (status code: {self.response.status})" - if self.text: - fmt = f"{fmt}: {self.text}" + retry_after: float + """How long the client should wait until it can make a request to the API again.""" - super().__init__(fmt) + def __init__(self, retry_after: float): + self.retry_after = retry_after + super().__init__( + f'Blocked by the API for an hour. Please try again in {retry_after} seconds.' + ) -class Unauthorized(HTTPException): - """Exception that's thrown when status code 401 occurs.""" - - -class UnauthorizedDetected(TopGGException): - """Exception that's thrown when no API Token is provided.""" - - -class Forbidden(HTTPException): - """Exception that's thrown when status code 403 occurs.""" - - -class NotFound(HTTPException): - """Exception that's thrown when status code 404 occurs.""" - - -class ServerError(HTTPException): - """Exception that's thrown when Top.gg returns "Server Error" responses (status codes such as 500 and 503).""" + def __repr__(self) -> str: + return f'<{__class__.__name__} retry_after={self.retry_after}>' diff --git a/topgg/http.py b/topgg/http.py deleted file mode 100644 index 408cee5e..00000000 --- a/topgg/http.py +++ /dev/null @@ -1,244 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021 Assanali Mukhanov -Copyright (c) 2024-2025 null8626 - -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__ = ("HTTPClient",) - -import asyncio -import json -import logging -import sys -from time import time -from typing import Any, Coroutine, Dict, Iterable, List, Optional, Sequence, Union, cast, Tuple - -import aiohttp -from aiohttp import ClientResponse - -from . import __version__, errors -from .ratelimiter import AsyncRateLimiter, AsyncRateLimiterManager - -_LOGGER = logging.getLogger("topgg.http") - - -async def _json_or_text( - response: ClientResponse, -) -> Union[dict, str]: - text = await response.text() - if response.headers["Content-Type"] == "application/json; charset=utf-8": - return json.loads(text) - return text - - -BASE = "https://top.gg/api" - - -class HTTPClient: - """Represents an HTTP client sending HTTP requests to the Top.gg API. - - .. _aiohttp session: https://aiohttp.readthedocs.io/en/stable/client_reference.html#client-session - - Args: - token (str) - A Top.gg API Token. - - Keyword Arguments: - session: `aiohttp session`_ - The `aiohttp session`_ used for requests to the API. - **kwargs: - Arbitrary kwargs to be passed to :class:`aiohttp.ClientSession`. - """ - - __slots__: Tuple[str, ...] = ( - "token", - "_own_session", - "session", - "global_rate_limiter", - "bot_rate_limiter", - "rate_limiters", - "user_agent", - ) - - def __init__( - self, - token: str, - *, - session: Optional[aiohttp.ClientSession] = None, - **kwargs: Any, - ) -> None: - self.token = token - self._own_session = session is None - self.session: aiohttp.ClientSession = session or aiohttp.ClientSession(**kwargs) - self.global_rate_limiter = AsyncRateLimiter(max_calls=99, period=1, callback=_rate_limit_handler) - self.bot_rate_limiter = AsyncRateLimiter(max_calls=59, period=60, callback=_rate_limit_handler) - self.rate_limiters = AsyncRateLimiterManager([self.global_rate_limiter, self.bot_rate_limiter]) - self.user_agent = ( - f"topggpy (https://github.com/top-gg-community/python-sdk {__version__}) Python/" - f"{sys.version_info[0]}.{sys.version_info[1]} aiohttp/{aiohttp.__version__}" - ) - - async def request(self, method: str, endpoint: str, **kwargs: Any) -> dict: - """Handles requests to the API.""" - rate_limiters = self.rate_limiters if endpoint.startswith("/bots") else self.global_rate_limiter - url = BASE + endpoint - - if not self.token: - raise errors.UnauthorizedDetected("Top.gg API token not provided") - - headers = { - "User-Agent": self.user_agent, - "Content-Type": "application/json", - "Authorization": self.token, - } - - if "json" in kwargs: - kwargs["data"] = to_json(kwargs.pop("json")) - - kwargs["headers"] = headers - - for _ in range(2): - async with rate_limiters: # type: ignore - async with self.session.request(method, url, **kwargs) as resp: - _LOGGER.debug( - "%s %s with %s has returned %s", - method, - url, - kwargs.get("data"), - resp.status, - ) - - data = await _json_or_text(resp) - - if 300 > resp.status >= 200: - return cast(dict, data) - - elif resp.status == 429: # we are being ratelimited - fmt = "We are being ratelimited. Retrying in %.2f seconds (%.3f minutes)." - - # sleep a bit - retry_after = float(resp.headers["Retry-After"]) - mins = retry_after / 60 - _LOGGER.warning(fmt, retry_after, mins) - - # check if it's a global ratelimit (True as only 1 ratelimit atm - /api/bots) - # is_global = True - # is_global = data.get('global', False) - # if is_global: - # self._global_over.clear() - - await asyncio.sleep(retry_after) - _LOGGER.debug("Done sleeping for the ratelimit. Retrying...") - - # release the global lock now that the - # global ratelimit has passed - # if is_global: - # self._global_over.set() - _LOGGER.debug("Global ratelimit is now over.") - continue - - elif resp.status == 400: - raise errors.HTTPException(resp, data) - elif resp.status == 401: - raise errors.Unauthorized(resp, data) - elif resp.status == 403: - raise errors.Forbidden(resp, data) - elif resp.status == 404: - raise errors.NotFound(resp, data) - elif resp.status >= 500: - raise errors.ServerError(resp, data) - - # We've run out of retries, raise. - raise errors.HTTPException(resp, data) - - async def close(self) -> None: - if self._own_session: - await self.session.close() - - async def post_guild_count(self, guild_count: Optional[int]) -> None: - """Posts bot's guild count on Top.gg.""" - await self.request("POST", "/bots/stats", json={"server_count": guild_count}) - - def get_weekend_status(self) -> Coroutine[Any, Any, dict]: - """Gets the weekend status from Top.gg.""" - return self.request("GET", "/weekend") - - def get_guild_count(self, bot_id: int) -> Coroutine[Any, Any, dict]: - """Gets the guild count of the given Bot ID.""" - return self.request("GET", f"/bots/{bot_id}/stats") - - def get_bot_info(self, bot_id: int) -> Coroutine[Any, Any, dict]: - """Gets the information of a bot under given bot ID on Top.gg.""" - return self.request("GET", f"/bots/{bot_id}") - - def get_bot_votes(self, bot_id: int) -> Coroutine[Any, Any, Iterable[dict]]: - """Gets your bot's last 1000 votes on Top.gg.""" - return self.request("GET", f"/bots/{bot_id}/votes") - - def get_bots( - self, - limit: int, - offset: int, - sort: str, - search: Dict[str, str], - fields: Sequence[str], - ) -> Coroutine[Any, Any, dict]: - limit = min(limit, 500) - fields = ", ".join(fields) - search = " ".join(f"{field}: {value}" for field, value in search.items()) - - return self.request( - "GET", - "/bots", - params={ - "limit": limit, - "offset": offset, - "sort": sort, - "search": search, - "fields": fields, - }, - ) - - def get_user_info(self, user_id: int) -> Coroutine[Any, Any, dict]: - """Gets an object of the user on Top.gg.""" - return self.request("GET", f"/users/{user_id}") - - def get_user_vote(self, bot_id: int, user_id: int) -> Coroutine[Any, Any, dict]: - """Gets info whether the user has voted for your bot.""" - return self.request("GET", f"/bots/{bot_id}/check", params={"userId": user_id}) - - -async def _rate_limit_handler(until: float) -> None: - """Handles the displayed message when we are ratelimited.""" - duration = round(until - time()) - mins = duration / 60 - fmt = "We have exhausted a ratelimit quota. Retrying in %.2f seconds (%.3f minutes)." - _LOGGER.warning(fmt, duration, mins) - - -def to_json(obj: Any) -> str: - kwargs = {"ensure_ascii": True} - - if json.__name__ != "ujson": - kwargs["separators"] = (",", ":") - - return json.dumps(obj, **kwargs) diff --git a/topgg/models.py b/topgg/models.py new file mode 100644 index 00000000..61f38444 --- /dev/null +++ b/topgg/models.py @@ -0,0 +1,360 @@ +""" +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 typing import Any, List, Optional, Tuple +from datetime import datetime, timezone +from urllib.parse import quote + + +def truthy_only(value: Optional[Any], default: Any = None) -> Optional[Any]: + if value: + return value + + return default + + +class Voter: + __slots__: Tuple[str, ...] = ('id', 'name', 'avatar') + + id: int + """This voter's user ID.""" + + name: str + """This voter's username.""" + + avatar: str + """This voter's avatar URL. Its format will either be PNG or GIF if animated.""" + + def __init__(self, json: dict): + self.id = int(json['id']) + self.name = json['username'] + + if avatar_hash := json.get('avatar'): + ext = 'gif' if avatar_hash.startswith('a_') else 'png' + self.avatar = ( + f'https://cdn.discordapp.com/avatars/{self.id}/{avatar_hash}.{ext}?size=1024' + ) + else: + self.avatar = f'https://cdn.discordapp.com/embed/avatars/{(self.id >> 2) % 6}.png' + + @property + def created_at(self) -> datetime: + """This voter's creation date.""" + + return datetime.fromtimestamp( + ((self.id >> 22) + 1420070400000) // 1000, tz=timezone.utc + ) + + +class Bot: + __slots__: Tuple[str, ...] = ( + 'id', + 'topgg_id', + 'name', + 'prefix', + 'short_description', + 'long_description', + 'tags', + 'website', + 'github', + 'owners', + 'banner_url', + 'approved_at', + 'votes', + 'monthly_votes', + 'support', + 'avatar', + 'url', + ) + + id: int + """This bot's application ID.""" + + topgg_id: int + """This bot's Top.gg user ID.""" + + name: str + """This bot's username.""" + + prefix: str + """This bot's prefix.""" + + short_description: str + """This bot's short description.""" + + long_description: Optional[str] + """This bot's long description. This can contain HTML and/or Markdown.""" + + tags: List[str] + """This bot's tags.""" + + website: Optional[str] + """This bot's website URL.""" + + github: Optional[str] + """This bot's GitHub repository URL.""" + + owners: List[int] + """A list of this bot's owners IDs.""" + + banner_url: Optional[str] + """This bot's banner URL.""" + + approved_at: datetime + """The date when this bot was approved on Top.gg.""" + + votes: int + """The amount of votes this bot has.""" + + monthly_votes: int + """The amount of votes this bot has this month.""" + + support: Optional[str] + """This bot's support server invite URL.""" + + avatar: str + """This bot's avatar URL. Its format will either be PNG or GIF if animated.""" + + url: str + """The URL of this bot's page.""" + + def __init__(self, json: dict): + self.id = int(json['clientid']) + self.topgg_id = int(json['id']) + self.name = json['username'] + self.prefix = json['prefix'] + self.short_description = json['shortdesc'] + self.long_description = truthy_only(json.get('longdesc')) + self.tags = json.get('tags') or [] + self.website = truthy_only(json.get('website')) + self.github = truthy_only(json.get('github')) + self.owners = [int(id) for id in json.get('owners') or []] + self.banner_url = truthy_only(json.get('bannerUrl')) + self.approved_at = datetime.fromisoformat(json['date'].replace('Z', '+00:00')) + self.votes = json['points'] + self.monthly_votes = json['monthlyPoints'] + + if support := json.get('support'): + self.support = f'https://discord.com/invite/{support}' + else: + self.support = None + + if avatar_hash := json.get('avatar'): + ext = 'gif' if avatar_hash.startswith('a_') else 'png' + self.avatar = ( + f'https://cdn.discordapp.com/avatars/{self.id}/{avatar_hash}.{ext}?size=1024' + ) + else: + self.avatar = f'https://cdn.discordapp.com/embed/avatars/{(self.id >> 2) % 6}.png' + + self.url = f'https://top.gg/bot/{json.get("support") or self.id}' + + @property + def created_at(self) -> datetime: + """This bot's creation date.""" + + return datetime.fromtimestamp( + ((self.id >> 22) + 1420070400000) // 1000, tz=timezone.utc + ) + + +class BotQuery: + __slots__: Tuple[str, ...] = ('__client', '__params', '__search', '__sort') + + def __init__(self, client: object): + self.__client = client + self.__params = {} + self.__search = {} + self.__sort = None + + def sort_by_id(self) -> 'BotQuery': + """ + Sorts results based on each Discord bot's ID. + + :returns: The same object. This allows this object to have chained method calls. + :rtype: :class:`~.models.BotQuery` + """ + + self.__sort = 'id' + + return self + + def sort_by_approval_date(self) -> 'BotQuery': + """ + Sorts results based on each Discord bot's approval date. + + :returns: The same object. This allows this object to have chained method calls. + :rtype: :class:`~.models.BotQuery` + """ + + self.__sort = 'date' + + return self + + def sort_by_monthly_votes(self) -> 'BotQuery': + """ + Sorts results based on each Discord bot's monthly vote count. + + :returns: The same object. This allows this object to have chained method calls. + :rtype: :class:`~.models.BotQuery` + """ + + self.__sort = 'monthlyPoints' + + return self + + def limit(self, limit: int) -> 'BotQuery': + """ + Sets the maximum amount of bots to be queried. + + :param id: The maximum amount of bots to be queried. This cannot be more than 500. + :type id: :py:class:`int` + + :returns: The same object. This allows this object to have chained method calls. + :rtype: :class:`~.models.BotQuery` + """ + + self.__params['limit'] = max(min(limit, 500), 1) + + return self + + def skip(self, skip: int) -> 'BotQuery': + """ + Sets the amount of bots to be skipped during the query. + + :param id: The amount of bots to be skipped during the query. This cannot be more than 499. + :type id: :py:class:`int` + + :returns: The same object. This allows this object to have chained method calls. + :rtype: :class:`~.models.BotQuery` + """ + + self.__params['skip'] = max(min(skip, 499), 0) + + return self + + def name(self, name: str) -> 'BotQuery': + """ + Queries only Discord bots that has this username. + + :param id: The specified username. + :type id: :py:class:`str` + + :returns: The same object. This allows this object to have chained method calls. + :rtype: :class:`~.models.BotQuery` + """ + + self.__search['username'] = name + + return self + + def prefix(self, prefix: str) -> 'BotQuery': + """ + Queries only Discord bots that has this prefix. + + :param id: The specified prefix. + :type id: :py:class:`str` + + :returns: The same object. This allows this object to have chained method calls. + :rtype: :class:`~.models.BotQuery` + """ + + self.__search['prefix'] = prefix + + return self + + def votes(self, votes: int) -> 'BotQuery': + """ + Queries only Discord bots that has this vote count. + + :param id: The specified vote count. + :type id: :py:class:`int` + + :returns: The same object. This allows this object to have chained method calls. + :rtype: :class:`~.models.BotQuery` + """ + + self.__search['points'] = max(votes, 0) + + return self + + def monthly_votes(self, monthly_votes: int) -> 'BotQuery': + """ + Queries only Discord bots that has this monthly vote count. + + :param id: The specified monthly vote count. + :type id: :py:class:`int` + + :returns: The same object. This allows this object to have chained method calls. + :rtype: :class:`~.models.BotQuery` + """ + + self.__search['monthlyPoints'] = max(monthly_votes, 0) + + return self + + def vanity(self, vanity: str) -> 'BotQuery': + """ + Queries only Discord bots that has this Top.gg vanity URL. + + :param id: The specified Top.gg vanity URL (without the preceeding https://top.gg/). + :type id: :py:class:`str` + + :returns: The same object. This allows this object to have chained method calls. + :rtype: :class:`~.models.BotQuery` + """ + + self.__search['vanity'] = vanity + + return self + + async def send(self) -> List[Bot]: + """ + Sends the query to the API and returns a list of matching Discord bots. + + :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. + :exception RequestError: If the client received a non-favorable response from the API. + :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. + + :returns: A list of matching discord bots. + :rtype: List[:class:`~.models.Bot`] + """ + + params = self.__params.copy() + params['search'] = [f'{k}%3A%20{quote(v)}' for k, v in self.__search.values()].join( + '%20' + ) + + if self.__sort: + params['sort'] = self.__sort + + bots = await self.__client.__request('GET', '/bots', params=params) + output = [] + + if bots: + for b in bots.get('results', ()): + output.append(Bot(b)) + + return output diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index 84ea5dad..cc8a8d84 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -1,110 +1,95 @@ """ The MIT License (MIT) -Copyright (c) 2021 Assanali Mukhanov +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: +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 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, +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. +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 collections + from time import time +from collections import deque from types import TracebackType -from typing import Any, Awaitable, Callable, List, Optional, Type, Tuple - - -class AsyncRateLimiter: - """ - Provides rate limiting for an operation with a configurable number of requests for a time period. - """ - - __slots__: Tuple[str, ...] = ("__lock", "callback", "max_calls", "period", "calls") - - __lock: asyncio.Lock - callback: Optional[Callable[[float], Awaitable[Any]]] - max_calls: int - period: float - calls: collections.deque - - def __init__( - self, - max_calls: int, - period: float = 1.0, - callback: Optional[Callable[[float], Awaitable[Any]]] = None, - ): - if period <= 0: - raise ValueError("Rate limiting period should be > 0") - if max_calls <= 0: - raise ValueError("Rate limiting number of calls should be > 0") - self.calls = collections.deque() - - self.period = period - self.max_calls = max_calls - self.callback = callback - self.__lock = asyncio.Lock() - - async def __aenter__(self) -> "AsyncRateLimiter": - async with self.__lock: - if len(self.calls) >= self.max_calls: - until = time() + self.period - self._timespan - if self.callback: - asyncio.ensure_future(self.callback(until)) - if (sleep_time := until - time()) > 0: - await asyncio.sleep(sleep_time) - return self - - async def __aexit__( - self, - exc_type: Type[BaseException], - exc_val: BaseException, - exc_tb: TracebackType, - ) -> None: - async with self.__lock: - # Store the last operation timestamp. - self.calls.append(time()) - - while self._timespan >= self.period: - self.calls.popleft() - - @property - def _timespan(self) -> float: - return self.calls[-1] - self.calls[0] - - -class AsyncRateLimiterManager: - __slots__: Tuple[str, ...] = ("rate_limiters",) - - rate_limiters: List[AsyncRateLimiter] - - def __init__(self, rate_limiters: List[AsyncRateLimiter]): - self.rate_limiters = rate_limiters - - async def __aenter__(self) -> "AsyncRateLimiterManager": - for manager in self.rate_limiters: - await manager.__aenter__() - - return self - - async def __aexit__( - self, - exc_type: Type[BaseException], - exc_val: BaseException, - exc_tb: TracebackType, - ) -> None: - await asyncio.gather(*(manager.__aexit__(exc_type, exc_val, exc_tb) for manager in self.rate_limiters)) +from typing import Type, Tuple, Iterable + + +class Ratelimiter: + __slots__: Tuple[str, ...] = ('__lock', '__max_calls', '__period', '__calls') + + def __init__( + self, + max_calls: int, + period: float = 1.0, + ): + self.__calls = deque() + self.__period = period + self.__max_calls = max_calls + self.__lock = asyncio.Lock() + + async def __aenter__(self) -> 'Ratelimiter': + async with self.__lock: + if len(self.__calls) >= self.__max_calls: + until = time() + self.__period - self._timespan + + if (sleep_time := until - time()) > 0: + await asyncio.sleep(sleep_time) + + return self + + async def __aexit__( + self, + _exc_type: Type[BaseException], + _exc_val: BaseException, + _exc_tb: TracebackType, + ) -> None: + async with self.__lock: + # Store the last operation timestamp. + self.__calls.append(time()) + + while self._timespan >= self.__period: + self.__calls.popleft() + + @property + def _timespan(self) -> float: + return self.__calls[-1] - self.__calls[0] + + +class RatelimiterManager: + __slots__: Tuple[str, ...] = ('__ratelimiters',) + + def __init__(self, ratelimiters: Iterable[Ratelimiter]): + self.__ratelimiters = ratelimiters + + async def __aenter__(self) -> 'RatelimiterManager': + for manager in self.__ratelimiters: + await manager.__aenter__() + + return self + + async def __aexit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> None: + await asyncio.gather( + *(manager.__aexit__(exc_type, exc_val, exc_tb) for manager in self.__ratelimiters) + ) diff --git a/topgg/types.py b/topgg/types.py deleted file mode 100644 index b65428ad..00000000 --- a/topgg/types.py +++ /dev/null @@ -1,386 +0,0 @@ -""" -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") - -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() - - if query := data.get("query", "").lstrip("?"): - 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(): - if key != (converted_key := camel_to_snake(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 value == "": - value = None - elif "id" in key.lower(): - if isinstance(value, str) and value.isdigit(): - value = int(value) - else: - continue - - if key != (converted_key := camel_to_snake(key)): - del data[key] - - data[converted_key] = value - - return data - - -def parse_bot_dict(d: dict) -> dict: - data = parse_dict(d.copy()) - - if (date := data.get("date")) and not isinstance(date, datetime): - data["date"] = datetime.fromisoformat(date.replace("Z", "+00:00")) - - if owners := data.get("owners"): - data["owners"] = [int(e) for e in owners] - - # TODO: remove this soon - data.pop("defAvatar", None) - data.pop("discriminator", None) - data.pop("guilds", None) - data.pop("certifiedBot", None) - - 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_user_dict(d: dict) -> dict: - data = d.copy() - - # TODO: remove this soon - data.pop("discriminator", None) - data.pop("certifiedDev", None) - - data["social"] = SocialData(**data.get("social", {})) - - return data - - -def parse_bot_stats_dict(d: dict) -> dict: - data = d.copy() - - if "server_count" not in data: - data["server_count"] = None - - return data - - -class DataDict(dict, t.MutableMapping[KT, VT]): - """Base class used to represent received data from the API. - - Every data model subclasses this class. - """ - - def __init__(self, **kwargs: VT) -> None: - super().__init__(**parse_dict(kwargs)) - self.__dict__ = self - - -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`.""" - - __slots__: t.Tuple[str, ...] = () - - 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.""" - - 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, - ): - 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 - `__.""" - - __slots__: t.Tuple[str, ...] = () - - id: int - """The ID of the bot.""" - - username: str - """The username of the bot.""" - - avatar: t.Optional[str] - """The avatar hash of the bot.""" - - prefix: str - """The prefix of the bot.""" - - shortdesc: str - """The brief description of the bot.""" - - longdesc: t.Optional[str] - """The long description of the bot.""" - - tags: t.List[str] - """The tags the bot has.""" - - website: t.Optional[str] - """The website of the bot.""" - - support: t.Optional[str] - """The invite code of the bot's support server.""" - - github: t.Optional[str] - """The GitHub URL of the repo of the bot.""" - - owners: t.List[int] - """The IDs of the owners of the bot.""" - - invite: t.Optional[str] - """The invite URL of the bot.""" - - date: datetime - """The time the bot was added.""" - - vanity: t.Optional[str] - """The vanity URL of the bot.""" - - points: int - """The amount of the votes the bot has.""" - - monthly_points: int - """The amount of the votes the bot has this month.""" - - donatebotguildid: int - """The guild ID for the donatebot setup.""" - - def __init__(self, **kwargs: t.Any): - super().__init__(**parse_bot_dict(kwargs)) - - -class BotStatsData(DataDict[str, t.Any]): - """Model that contains information about a listed bot's guild count.""" - - __slots__: t.Tuple[str, ...] = () - - server_count: t.Optional[int] - """The amount of servers the bot is in.""" - - def __init__(self, **kwargs: t.Any): - super().__init__(**parse_bot_stats_dict(kwargs)) - - -class BriefUserData(DataDict[str, t.Any]): - """Model that contains brief information about a Top.gg user.""" - - __slots__: t.Tuple[str, ...] = () - - id: int - """The Discord ID of the user.""" - username: str - """The Discord username of the user.""" - avatar: str - """The Discord avatar URL of the user.""" - - def __init__(self, **kwargs: t.Any): - if kwargs["id"].isdigit(): - kwargs["id"] = int(kwargs["id"]) - super().__init__(**kwargs) - - -class SocialData(DataDict[str, str]): - """Model that contains social information about a top.gg user.""" - - __slots__: t.Tuple[str, ...] = () - - youtube: str - """The YouTube channel ID of the user.""" - reddit: str - """The Reddit username of the user.""" - twitter: str - """The Twitter username of the user.""" - instagram: str - """The Instagram username of the user.""" - github: str - """The GitHub username of the 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__: t.Tuple[str, ...] = () - - id: int - """The ID of the user.""" - - username: str - """The username of the user.""" - - social: SocialData - """The social data of the user.""" - - color: str - """The custom hex color of the user.""" - - supporter: bool - """Whether or not the user is a supporter.""" - - mod: bool - """Whether or not the user is a Top.gg mod.""" - - web_mod: bool - """Whether or not the user is a Top.gg web mod.""" - - admin: bool - """Whether or not the user is a Top.gg admin.""" - - def __init__(self, **kwargs: t.Any): - super().__init__(**parse_user_dict(kwargs)) - - -class VoteDataDict(DataDict[str, t.Any]): - """Base model that represents received information from Top.gg via webhooks.""" - - __slots__: t.Tuple[str, ...] = () - - type: str - """Type of the action (``upvote`` or ``test``).""" - user: int - """ID of the voter.""" - query: DataDict - """Query parameters in :obj:`.DataDict`.""" - - def __init__(self, **kwargs: t.Any): - super().__init__(**parse_vote_dict(kwargs)) - - -class BotVoteData(VoteDataDict): - """Model that contains information about a bot vote.""" - - __slots__: t.Tuple[str, ...] = () - - bot: int - """ID of the bot the user voted for.""" - is_weekend: bool - """Boolean value indicating whether the action was done on a weekend.""" - - -class GuildVoteData(VoteDataDict): - """Model that contains information about a guild vote.""" - - __slots__: t.Tuple[str, ...] = () - - guild: int - """ID of the guild the user voted for.""" - - -ServerVoteData = GuildVoteData - - -@dataclasses.dataclass -class StatsWrapper: - guild_count: int - """The guild count.""" diff --git a/topgg/webhook.py b/topgg/webhook.py deleted file mode 100644 index 9b947d8f..00000000 --- a/topgg/webhook.py +++ /dev/null @@ -1,359 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021 Assanali Mukhanov -Copyright (c) 2024-2025 null8626 - -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", -) - -import enum -import typing as t - -import aiohttp -from aiohttp import web - -from topgg.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"]] - - -class WebhookType(enum.Enum): - """An enum that represents the type of an endpoint.""" - - __slots__: t.Tuple[str, ...] = () - - BOT = enum.auto() - """Marks the endpoint as a bot webhook.""" - - GUILD = enum.auto() - """Marks the endpoint as a guild webhook.""" - - -class WebhookManager(DataContainerMixin): - """ - A class for managing Top.gg webhooks. - """ - - __slots__: t.Tuple[str, ...] = ("__app", "_webserver", "_is_running") - - __app: web.Application - _webserver: web.TCPSite - _is_running: bool - - def __init__(self) -> None: - super().__init__() - - self.__app = web.Application() - self._is_running = False - - @t.overload - def endpoint(self, endpoint_: None = None) -> "BoundWebhookEndpoint": ... - - @t.overload - def endpoint(self, endpoint_: "WebhookEndpoint") -> "WebhookManager": ... - - def endpoint(self, endpoint_: t.Optional["WebhookEndpoint"] = None) -> t.Any: - """Helper method that returns a WebhookEndpoint object. - - Args: - `endpoint_` (Optional[:obj:`WebhookEndpoint`]) - The endpoint to add. - - Returns: - Union[:obj:`WebhookManager`, :obj:`BoundWebhookEndpoint`]: - An instance of :obj:`WebhookManager` if endpoint was provided, - otherwise :obj:`BoundWebhookEndpoint`. - - Raises: - :exc:`~.errors.TopGGException` - If the endpoint is lacking attributes. - """ - 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.") - - self.app.router.add_post( - endpoint_._route, - self._get_handler(endpoint_._type, endpoint_._auth, endpoint_._callback), - ) - return self - - return BoundWebhookEndpoint(manager=self) - - async def start(self, port: int) -> None: - """Runs the webhook. - - Args: - port (int) - The port to run the webhook on. - """ - - runner = web.AppRunner(self.__app) - await runner.setup() - 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.""" - return self._is_running - - @property - def app(self) -> web.Application: - """Returns the internal web application that handles webhook 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() - await self.__app.shutdown() - 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") - - data = await request.json() - await self._invoke_callback( - callback, - (BotVoteData if type_ is WebhookType.BOT else GuildVoteData)(**data), - ) - return web.Response(status=200, text="OK") - - return _handler - - -CallbackT = t.Callable[..., t.Any] - - -class WebhookEndpoint: - """ - A helper class to setup webhook endpoint. - """ - - __slots__: t.Tuple[str, ...] = ("_callback", "_auth", "_route", "_type") - - def __init__(self) -> None: - 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. - - Args: - `type_` (:obj:`WebhookType`) - The type of the endpoint. - - Returns: - :obj:`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. - - Returns: - :obj:`WebhookEndpoint` - """ - self._route = route_ - return self - - def auth(self: T, auth_: str) -> T: - """ - Sets the auth of this endpoint. - - Args: - `auth_` (str) - The auth of this endpoint. - - Returns: - :obj:`WebhookEndpoint` - """ - self._auth = auth_ - return self - - @t.overload - 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.Any: - """ - Registers a vote callback, called whenever this endpoint receives POST requests. - - The callback can be either sync or async. - This method can be used as a decorator or a decorator factory. - - :Example: - .. code-block:: python - - import topgg - - webhook_manager = topgg.WebhookManager() - endpoint = topgg.WebhookEndpoint().type(topgg.WebhookType.BOT).route("/dblwebhook").auth("youshallnotpass") - - # 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): ... - - - # Used as decorator factory, the decorated function will still be the function itself. - @endpoint.callback() - def on_vote(vote_data: topgg.BotVoteData): ... - - - webhook_manager.endpoint(endpoint) - """ - if callback_ is not None: - self._callback = callback_ - return self - - return self.callback - - -class BoundWebhookEndpoint(WebhookEndpoint): - """ - A WebhookEndpoint with a WebhookManager bound to it. - - You can instantiate this object using the :meth:`WebhookManager.endpoint` method. - - :Example: - .. code-block:: python - - import topgg - - webhook_manager = ( - topgg.WebhookManager().endpoint().type(topgg.WebhookType.BOT).route("/dblwebhook").auth("youshallnotpass") - ) - - # 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 factory, the decorated function will still be the function itself. - @endpoint.callback() - def on_vote(vote_data: topgg.BotVoteData): ... - - - endpoint.add_to_manager() - """ - - __slots__: t.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` - - Raises: - :obj:`errors.TopGGException`: - If the object lacks attributes. - """ - self.manager.endpoint(self) - return self.manager - - -def endpoint(route: str, type: WebhookType, auth: str = "") -> t.Callable[[t.Callable[..., t.Any]], WebhookEndpoint]: - """ - A decorator factory for instantiating WebhookEndpoint. - - Args: - route (str) - The route for the endpoint. - type (WebhookType) - The type of the endpoint. - auth (str) - The auth for the endpoint. - - Returns: - Callable[[Callable[..., Any]], :obj:`WebhookEndpoint`]: - The actual decorator. - - :Example: - .. code-block:: python - - import topgg - - - @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), - ): ... - """ - - def decorator(func: t.Callable[..., t.Any]) -> WebhookEndpoint: - return WebhookEndpoint().route(route).type(type).auth(auth).callback(func) - - return decorator From 33876d17f3bb293ecc1fc27200249494a354a101 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sun, 23 Feb 2025 14:27:51 +0700 Subject: [PATCH 075/126] doc: reword documentation --- .gitignore | 2 +- docs/_static/script.js | 2 +- topgg/__init__.py | 2 +- topgg/client.py | 59 ++++++++++++++++++++---------------------- topgg/models.py | 22 ++++++++-------- topgg/ratelimiter.py | 9 +++---- 6 files changed, 46 insertions(+), 50 deletions(-) diff --git a/.gitignore b/.gitignore index 92119565..f222ed9d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ **/__pycache__/ -topstats.egg-info/ +topggpy.egg-info/ docs/_build/ dist/ .ruff_cache/ diff --git a/docs/_static/script.js b/docs/_static/script.js index bdb8ea00..b576dcd5 100644 --- a/docs/_static/script.js +++ b/docs/_static/script.js @@ -12,7 +12,7 @@ document.addEventListener('load', () => { const tocDrawer = document.querySelector('aside.toc-drawer') -if (document.querySelector('section#topstats')) { +if (document.querySelector('section#topggpy')) { // we don't need the right sidebar on the main landing page tocDrawer.remove() } else { diff --git a/topgg/__init__.py b/topgg/__init__.py index 05652dc7..b7a937d3 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -23,9 +23,9 @@ SOFTWARE. """ -from .client import Client from .errors import Error, RequestError, Ratelimited from .models import Bot, BotQuery, Voter +from .client import Client __title__ = 'topggpy' __author__ = 'null8626 & Top.gg' diff --git a/topgg/client.py b/topgg/client.py index 9a1d0201..bbbc6a97 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -27,9 +27,9 @@ from typing import Optional, Tuple, List from collections import namedtuple from base64 import b64decode +from asyncio import sleep +from json import loads from time import time -import asyncio -import json from .models import Bot, BotQuery, Voter from .errors import Error, RequestError, Ratelimited @@ -43,13 +43,13 @@ class Client: """ The class that lets you interact with the API. - :param token: The API token to use with the API. To retrieve your topstats.gg API token, see https://docs.top.gg/docs/API/@reference. + :param token: The API token to use with the API. To retrieve your API token, see https://docs.top.gg/docs/API/@reference. :type token: :py:class:`str` :param session: Whether to use an existing :class:`~aiohttp.ClientSession` for requesting or not. Defaults to :py:obj:`None` (creates a new one instead) :type session: Optional[:class:`~aiohttp.ClientSession`] - :raises TypeError: If ``token`` is not a :py:class:`str`. - :raises ValueError: If ``token`` is not a valid API token. + :exception TypeError: If ``token`` is not a :py:class:`str`. + :exception ValueError: If ``token`` is not a valid API token. """ __slots__: Tuple[str, ...] = ( @@ -76,7 +76,7 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): encoded_json = token.split('.')[1] encoded_json += '=' * (4 - (len(encoded_json) % 4)) - self.id = int(json.loads(b64decode(encoded_json))['id']) + self.id = int(loads(b64decode(encoded_json))['id']) except: raise ValueError('Got a malformed Top.gg API token.') @@ -114,7 +114,7 @@ async def __request( ) status = None - headers = None + retry_after = None json = {} async with ratelimiter: @@ -131,40 +131,41 @@ async def __request( params=params, ) as resp: status = resp.status - headers = resp.headers json = await resp.json() - assert 200 <= status <= 299 + if retry_after_ms := json.get('expiresIn'): + retry_after = float(retry_after_ms) / 1000 + + resp.raise_for_status() + return json except: if status == 404 and treat_404_as_none: return elif status == 429: - retry_after = float(headers['Retry-After']) - if retry_after > MAXIMUM_DELAY_THRESHOLD: self.__current_ratelimit = time() + retry_after raise Ratelimited(retry_after) from None - else: - await asyncio.sleep(retry_after) - else: - raise RequestError(json, status) from None - return await self.__request(method, path) + await sleep(retry_after) + + return await self.__request(method, path) + + raise RequestError(json, status) from None async def get_bot(self, id: int) -> Optional[Bot]: """ - Fetches a Discord bot from its Discord ID. + Fetches a Discord bot from its ID. - :param id: The requested Discord ID. + :param id: The requested ID. :type id: :py:class:`int` :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. :exception RequestError: If the client received a non-favorable response from the API. :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. - :returns: The requested Discord bot. This can be :py:obj:`None` if the requested Discord bot does not exist. + :returns: The requested bot. This can be :py:obj:`None` if it does not exist. :rtype: Optional[:class:`~.models.Bot`] """ @@ -174,9 +175,9 @@ async def get_bot(self, id: int) -> Optional[Bot]: def get_bots(self) -> BotQuery: """ - Fetches a list of Discord bots that matches the specified bot query. + Fetches a list of Discord bots that matches the specified query. - :returns: A :class:`~.models.BotQuery` object, which allows you to configure a Discord bot query before sending it to the API to retrieve a list of Discord bots that matches the specified query. + :returns: A :class:`~.models.BotQuery` object, which allows you to configure a query before sending it to the API. :rtype: :class:`~.models.BotQuery` """ @@ -190,7 +191,7 @@ async def is_weekend(self) -> bool: :exception RequestError: If the client received a non-favorable response from the API. :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. - :returns: Whether the weekend multiplier is active or not. + :returns: Whether the weekend multiplier is active. :rtype: bool """ @@ -206,30 +207,26 @@ async def get_voters(self) -> List[Voter]: :exception RequestError: If the client received a non-favorable response from the API. :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. - :returns: Your Discord bot's last 1000 voters. + :returns: Your bot's last 1000 voters. :rtype: List[:class:`~.models.Voter`] """ voters = await self.__request('GET', f'/bots/{self.id}/votes') - output = [] - - for voter in voters or (): - output.append(Voter(voter)) - return output + return [Voter(voter) for voter in voters or ()] async def has_voted(self, id: int) -> bool: """ - Checks if the specified user has voted your Discord bot. + Checks if the specified Discord user has voted your Discord bot. - :param id: The requested user's Discord ID. + :param id: The requested user's ID. :type id: :py:class:`int` :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. :exception RequestError: If the client received a non-favorable response from the API. :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. - :returns: Whether the specified user has voted your Discord bot. + :returns: Whether the specified user has voted your bot. :rtype: bool """ diff --git a/topgg/models.py b/topgg/models.py index 61f38444..e4b39c8d 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -138,7 +138,7 @@ class Bot: """This bot's avatar URL. Its format will either be PNG or GIF if animated.""" url: str - """The URL of this bot's page.""" + """This bot's Top.gg page URL.""" def __init__(self, json: dict): self.id = int(json['clientid']) @@ -191,7 +191,7 @@ def __init__(self, client: object): def sort_by_id(self) -> 'BotQuery': """ - Sorts results based on each Discord bot's ID. + Sorts results based on each bot's ID. :returns: The same object. This allows this object to have chained method calls. :rtype: :class:`~.models.BotQuery` @@ -203,7 +203,7 @@ def sort_by_id(self) -> 'BotQuery': def sort_by_approval_date(self) -> 'BotQuery': """ - Sorts results based on each Discord bot's approval date. + Sorts results based on each bot's approval date. :returns: The same object. This allows this object to have chained method calls. :rtype: :class:`~.models.BotQuery` @@ -215,7 +215,7 @@ def sort_by_approval_date(self) -> 'BotQuery': def sort_by_monthly_votes(self) -> 'BotQuery': """ - Sorts results based on each Discord bot's monthly vote count. + Sorts results based on each bot's monthly vote count. :returns: The same object. This allows this object to have chained method calls. :rtype: :class:`~.models.BotQuery` @@ -257,7 +257,7 @@ def skip(self, skip: int) -> 'BotQuery': def name(self, name: str) -> 'BotQuery': """ - Queries only Discord bots that has this username. + Queries only bots that has this username. :param id: The specified username. :type id: :py:class:`str` @@ -272,7 +272,7 @@ def name(self, name: str) -> 'BotQuery': def prefix(self, prefix: str) -> 'BotQuery': """ - Queries only Discord bots that has this prefix. + Queries only bots that has this prefix. :param id: The specified prefix. :type id: :py:class:`str` @@ -287,7 +287,7 @@ def prefix(self, prefix: str) -> 'BotQuery': def votes(self, votes: int) -> 'BotQuery': """ - Queries only Discord bots that has this vote count. + Queries only bots that has this vote count. :param id: The specified vote count. :type id: :py:class:`int` @@ -302,7 +302,7 @@ def votes(self, votes: int) -> 'BotQuery': def monthly_votes(self, monthly_votes: int) -> 'BotQuery': """ - Queries only Discord bots that has this monthly vote count. + Queries only bots that has this monthly vote count. :param id: The specified monthly vote count. :type id: :py:class:`int` @@ -317,7 +317,7 @@ def monthly_votes(self, monthly_votes: int) -> 'BotQuery': def vanity(self, vanity: str) -> 'BotQuery': """ - Queries only Discord bots that has this Top.gg vanity URL. + Queries only bots that has this Top.gg vanity URL. :param id: The specified Top.gg vanity URL (without the preceeding https://top.gg/). :type id: :py:class:`str` @@ -332,13 +332,13 @@ def vanity(self, vanity: str) -> 'BotQuery': async def send(self) -> List[Bot]: """ - Sends the query to the API and returns a list of matching Discord bots. + Sends the query to the API. :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. :exception RequestError: If the client received a non-favorable response from the API. :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. - :returns: A list of matching discord bots. + :returns: A list of matching bots. :rtype: List[:class:`~.models.Bot`] """ diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index cc8a8d84..2984c05d 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -23,12 +23,11 @@ SOFTWARE. """ -import asyncio - -from time import time -from collections import deque -from types import TracebackType from typing import Type, Tuple, Iterable +from types import TracebackType +from collections import deque +from time import time +import asyncio class Ratelimiter: From 0a6f84e99011ba6b49ea63867c8f4be5820e9638 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sun, 23 Feb 2025 14:46:33 +0700 Subject: [PATCH 076/126] fix: fix potential errors in errors --- topgg/client.py | 17 ++++++++--------- topgg/errors.py | 4 ++-- topgg/models.py | 17 ++++++----------- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index bbbc6a97..bde047bc 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -24,7 +24,7 @@ """ from aiohttp import ClientSession, ClientTimeout -from typing import Optional, Tuple, List +from typing import Optional, Tuple, Iterable from collections import namedtuple from base64 import b64decode from asyncio import sleep @@ -131,10 +131,9 @@ async def __request( params=params, ) as resp: status = resp.status - json = await resp.json() + retry_after = float(resp.headers.get('Retry-After', 0)) - if retry_after_ms := json.get('expiresIn'): - retry_after = float(retry_after_ms) / 1000 + json = await resp.json() resp.raise_for_status() @@ -152,7 +151,7 @@ async def __request( return await self.__request(method, path) - raise RequestError(json, status) from None + raise RequestError(json.get('message'), status) from None async def get_bot(self, id: int) -> Optional[Bot]: """ @@ -199,21 +198,21 @@ async def is_weekend(self) -> bool: return response['is_weekend'] - async def get_voters(self) -> List[Voter]: + async def get_voters(self) -> Iterable[Voter]: """ - Fetches your Discord bot's last 1000 voters. + Fetches and yields your Discord bot's last 1000 voters. :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. :exception RequestError: If the client received a non-favorable response from the API. :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. :returns: Your bot's last 1000 voters. - :rtype: List[:class:`~.models.Voter`] + :rtype: Iterable[:class:`~.models.Voter`] """ voters = await self.__request('GET', f'/bots/{self.id}/votes') - return [Voter(voter) for voter in voters or ()] + return map(Voter, voters or ()) async def has_voted(self, id: int) -> bool: """ diff --git a/topgg/errors.py b/topgg/errors.py index b08757f9..ea8546d8 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -43,8 +43,8 @@ class RequestError(Error): status: Optional[int] """The status code returned from the API. This can be :py:obj:`None`.""" - def __init__(self, json: dict, status: Optional[int]): - self.message = json.get('message') + def __init__(self, message: Optional[str], status: Optional[int]): + self.message = message self.status = status super().__init__(f'Got {status}: {self.message!r}') diff --git a/topgg/models.py b/topgg/models.py index e4b39c8d..8f12d763 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -23,7 +23,7 @@ SOFTWARE. """ -from typing import Any, List, Optional, Tuple +from typing import Any, List, Iterable, Optional, Tuple from datetime import datetime, timezone from urllib.parse import quote @@ -330,7 +330,7 @@ def vanity(self, vanity: str) -> 'BotQuery': return self - async def send(self) -> List[Bot]: + async def send(self) -> Iterable[Bot]: """ Sends the query to the API. @@ -338,8 +338,8 @@ async def send(self) -> List[Bot]: :exception RequestError: If the client received a non-favorable response from the API. :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. - :returns: A list of matching bots. - :rtype: List[:class:`~.models.Bot`] + :returns: A generator of matching bots. + :rtype: Iterable[:class:`~.models.Bot`] """ params = self.__params.copy() @@ -350,11 +350,6 @@ async def send(self) -> List[Bot]: if self.__sort: params['sort'] = self.__sort - bots = await self.__client.__request('GET', '/bots', params=params) - output = [] + bots = (await self.__client.__request('GET', '/bots', params=params)) or {} - if bots: - for b in bots.get('results', ()): - output.append(Bot(b)) - - return output + return map(Bot, bots.get('results', ())) From ce0031f5a65fb6c11dd10d02db7165e6efffb106 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sun, 23 Feb 2025 23:34:00 +0700 Subject: [PATCH 077/126] feat: add get_server_count and post_server_count --- .github/workflows/release.yml | 21 ++++++++ .github/workflows/test.yml | 34 ++++++++++++ MANIFEST.in | 9 ++++ README.md | 44 +++++++++++++++- docs/index.rst | 97 +++++++++++++++++++---------------- test.py | 90 ++++++++++++++++++++++++++++++++ topgg/client.py | 57 ++++++++++++++++---- topgg/errors.py | 4 +- topgg/models.py | 40 ++++----------- 9 files changed, 309 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml create mode 100644 MANIFEST.in create mode 100644 test.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..bc2c4346 --- /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@v4 + - uses: actions/setup-python@v5 + 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..85c4a198 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: Run tests +on: + push: + branches: [master, v0-rewrite] + paths: + - '.github/workflows/test.yml' + - 'topgg/**/*.py' + pull_request: + paths: + - '.github/workflows/test.yml' + - 'topgg/**/*.py' +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.9, '3.10', 3.11, 3.12, 3.13] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: python3 -m pip install . + - name: Run tests + run: python3 test.py + env: + TOPGG_TOKEN: ${{ secrets.TOPGG_TOKEN }} + - name: Install dev dependencies + if: ${{ matrix.python-version == 3.13 }} + run: python3 -m pip install ruff + - name: Lint with ruff + if: ${{ matrix.python-version == 3.13 }} + run: python3 -m ruff check diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..0d704918 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +prune .github +prune .ruff_cache +prune docs +exclude .gitattributes +exclude .gitignore +exclude .readthedocs.yml +exclude ruff.toml +exclude test.py +exclude LICENSE \ No newline at end of file diff --git a/README.md b/README.md index ced90ed1..fcdb071c 100644 --- a/README.md +++ b/README.md @@ -23,14 +23,54 @@ import topgg import asyncio import os + async def main() -> None: + # Declare the client. to retrieve your top.gg token, see https://docs.top.gg/docs/API/@reference. - async with topgg.Client('your top.gg API token') as ts: + async with topgg.Client(os.getenv('TOPGG_TOKEN')) as tg: # Fetch a Discord bot from its ID. bot = await tg.get_bot(432610292342587392) - + print(bot) + # Fetch Discord bots that matches the specified query. + bots = ( + await tg.get_bots() + .limit(250) + .skip(50) + .name('shiro') + .sort_by_monthly_votes() + .send() + ) + + for b in bots: + print(b) + + # Post your Discord bot's server count to the API. This will update the server count in your bot's Top.gg page. + await tg.post_server_count(2) + + # Fetch your Discord bot's posted server count. + posted_server_count = await tg.get_server_count() + + # Fetch your Discord bot's last 1000 voters. + voters = await tg.get_voters() + + for voter in voters: + print(voter) + + # Check if a Discord user has voted your Discord bot. + has_voted = await tg.has_voted(661200758510977084) + + if has_voted: + print('This user has voted!') + + # Check if the weekend multiplier is active. + is_weekend = await tg.is_weekend() + + if is_weekend: + print('The weekend multiplier is active!') + + if __name__ == '__main__': # See https://stackoverflow.com/questions/45600579/asyncio-event-loop-is-closed-when-getting-loop # for more details. diff --git a/docs/index.rst b/docs/index.rst index 03bd4526..00c33369 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,75 +1,86 @@ -.. topstats documentation master file, created by - sphinx-quickstart on Sat Oct 14 19:20:61 2024. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -========= -topstats_ -========= +======== +topggpy_ +======== |pypi|_ |downloads|_ -.. _topstats: https://pypi.org/project/topstats/ -.. |pypi| image:: https://img.shields.io/pypi/v/topstats.svg?style=flat-square -.. _pypi: https://pypi.org/project/topstats/ -.. |downloads| image:: https://img.shields.io/pypi/dm/topstats?style=flat-square -.. _downloads: https://pypi.org/project/topstats/ +.. _topggpy: https://pypi.org/project/topggpy/ +.. |pypi| image:: https://img.shields.io/pypi/v/topggpy.svg?style=flat-square +.. _pypi: https://pypi.org/project/topggpy/ +.. |downloads| image:: https://img.shields.io/pypi/dm/topggpy?style=flat-square +.. _downloads: https://pypi.org/project/topggpy/ -The community-maintained Python API wrapper for `topstats.gg `_. +The community-maintained Python API wrapper for `Top.gg `_. Installation ------------ .. code-block:: console - $ pip install topstats + $ pip install topggpy Example ------- .. code-block:: python - # import the module - import topstats + # Import the module + import topgg import asyncio import os + async def main() -> None: - # declare the client. to retrieve your topstats.gg token, see https://docs.topstats.gg/authentication/tokens/ - async with topstats.Client('your topstats.gg API token') as ts: - # fetch a ranked bot from its bot ID - bot = await ts.get_bot(432610292342587392) - + + # Declare the client. to retrieve your top.gg token, see https://docs.top.gg/docs/API/@reference. + async with topgg.Client(os.getenv('TOPGG_TOKEN')) as tg: + # Fetch a Discord bot from its ID. + bot = await tg.get_bot(432610292342587392) + print(bot) - # fetch topstats.gg's top bots - bots = await ts.get_top_bots(sort_by=topstats.SortBy.server_count()) - + # Fetch Discord bots that matches the specified query. + bots = ( + await tg.get_bots() + .limit(250) + .skip(50) + .name('shiro') + .sort_by_monthly_votes() + .send() + ) + for b in bots: print(b) - - # compare two bots' historical server count - vs = await ts.compare_bot_server_count(432610292342587392, 437808476106784770) - for first, second in vs: - print(first, second) - - # compare up to four bots' historical total votes - vs2 = await ts.compare_bot_total_votes( - topstats.Period.LAST_YEAR, - 339254240012664832, - 432610292342587392, - 408785106942164992, - 437808476106784770 - ) + # Post your Discord bot's server count to the API. This will update the server count in your bot's Top.gg page. + await tg.post_server_count(2) + + # Fetch your Discord bot's posted server count. + posted_server_count = await tg.get_server_count() + + # Fetch your Discord bot's last 1000 voters. + voters = await tg.get_voters() + + for voter in voters: + print(voter) + + # Check if a Discord user has voted your Discord bot. + has_voted = await tg.has_voted(661200758510977084) + + if has_voted: + print('This user has voted!') + + # Check if the weekend multiplier is active. + is_weekend = await tg.is_weekend() + + if is_weekend: + print('The weekend multiplier is active!') - for first, second, third, fourth in vs2: - print(first, second, third, fourth) if __name__ == '__main__': - # see https://stackoverflow.com/questions/45600579/asyncio-event-loop-is-closed-when-getting-loop - # for more details + # See https://stackoverflow.com/questions/45600579/asyncio-event-loop-is-closed-when-getting-loop + # for more details. if os.name == 'nt': asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) diff --git a/test.py b/test.py new file mode 100644 index 00000000..e6b1997f --- /dev/null +++ b/test.py @@ -0,0 +1,90 @@ +import topgg + +from sys import stdout +import asyncio +import os + +INDENTATION = 2 + + +def is_local(data: object) -> bool: + return getattr(data, '__module__', '').startswith('topgg') + + +def _test_attributes(obj: object, indent_level: int) -> None: + for name in getattr(obj.__class__, '__slots__', ()): + stdout.write(f'{" " * indent_level}{obj.__class__.__name__}.{name}') + data = getattr(obj, name) + + if isinstance(data, list): + stdout.write('[0] -> ') + + for i, each in enumerate(data): + if i > 0: + stdout.write(f'{" " * indent_level}{obj.__class__.__name__}.{name}[{i}] -> ') + + print(repr(each)) + _test_attributes(each, indent_level + INDENTATION) + + continue + + print(f' -> {data!r}') + + if is_local(data): + _test_attributes(data, indent_level + INDENTATION) + + +def test_attributes(obj: object) -> None: + print(f'{obj!r} -> ') + _test_attributes(obj, INDENTATION) + + +async def run() -> None: + async with topgg.Client(os.getenv('TOPGG_TOKEN')) as tg: + bot = await tg.get_bot(432610292342587392) + + test_attributes(bot) + + await asyncio.sleep(1) + bots = ( + await tg.get_bots() + .limit(250) + .skip(50) + .name('shiro') + .sort_by_monthly_votes() + .send() + ) + + for b in bots: + test_attributes(b) + + await asyncio.sleep(1) + await tg.post_server_count(2) + + await asyncio.sleep(1) + posted_server_count = await tg.get_server_count() + + assert posted_server_count == 2 + + await asyncio.sleep(1) + voters = await tg.get_voters() + + for voter in voters: + test_attributes(voter) + + await asyncio.sleep(1) + has_voted = await tg.has_voted(661200758510977084) + + assert isinstance(has_voted, bool) + + await asyncio.sleep(1) + is_weekend = await tg.is_weekend() + + assert isinstance(is_weekend, bool) + + +if __name__ == '__main__': + if os.name == 'nt': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + asyncio.run(run()) diff --git a/topgg/client.py b/topgg/client.py index bde047bc..099dbf7f 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -80,10 +80,10 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): except: raise ValueError('Got a malformed Top.gg API token.') - endpoint_ratelimits = namedtuple('EndpointRatelimits', '_global bot') + endpoint_ratelimits = namedtuple('EndpointRatelimits', 'global_ bot') self.__ratelimiters = endpoint_ratelimits( - _global=Ratelimiter(99, 1), bot=Ratelimiter(59, 60) + global_=Ratelimiter(99, 1), bot=Ratelimiter(59, 60) ) self.__ratelimiter_manager = RatelimiterManager(self.__ratelimiters) self.__current_ratelimit = None @@ -92,7 +92,7 @@ async def __request( self, method: str, path: str, - params: dict = {}, + params: Optional[dict] = None, json: Optional[dict] = None, treat_404_as_none: bool = True, ) -> Optional[dict]: @@ -110,12 +110,20 @@ async def __request( ratelimiter = ( self.__ratelimiter_manager if path.startswith('/bots') - else self.__ratelimiters._global + else self.__ratelimiters.global_ ) + kwargs = {} + + if json: + kwargs['json'] = json + + if params: + kwargs['params'] = params + status = None retry_after = None - json = {} + json = None async with ratelimiter: try: @@ -127,13 +135,15 @@ async def __request( 'Content-Type': 'application/json', 'User-Agent': 'topggpy (https://github.com/top-gg-community/python-sdk 3.0.0) Python/', }, - json=json, - params=params, + **kwargs, ) as resp: status = resp.status retry_after = float(resp.headers.get('Retry-After', 0)) - json = await resp.json() + try: + json = await resp.json() + except: + pass resp.raise_for_status() @@ -174,7 +184,7 @@ async def get_bot(self, id: int) -> Optional[Bot]: def get_bots(self) -> BotQuery: """ - Fetches a list of Discord bots that matches the specified query. + Fetches and yields Discord bots that matches the specified query. :returns: A :class:`~.models.BotQuery` object, which allows you to configure a query before sending it to the API. :rtype: :class:`~.models.BotQuery` @@ -182,6 +192,35 @@ def get_bots(self) -> BotQuery: return BotQuery(self) + async def get_server_count(self) -> Optional[int]: + """ + Fetches your Discord bot's posted server count. + + :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. + :exception RequestError: If the client received a non-favorable response from the API. + :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. + + :returns: The posted server count. This can be :py:obj:`None` if it does not exist. + :rtype: Optional[:py:class:`int`] + """ + + stats = await self.__request('GET', f'/bots/{self.id}/stats') + + return stats and stats.get('server_count') + + async def post_server_count(self, new_server_count: int): + """ + Posts your Discord bot's server count to the API. This will update the server count in your bot's Top.gg page. + + :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. + :exception RequestError: If the client received a non-favorable response from the API. + :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. + """ + + await self.__request( + 'POST', f'/bots/{self.id}/stats', json={'server_count': new_server_count} + ) + async def is_weekend(self) -> bool: """ Checks if the weekend multiplier is active. diff --git a/topgg/errors.py b/topgg/errors.py index ea8546d8..3638ce9a 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -38,10 +38,10 @@ class RequestError(Error): __slots__: Tuple[str, ...] = ('message', 'status') message: Optional[str] - """The message returned from the API. This can be :py:obj:`None`.""" + """The message returned from the API.""" status: Optional[int] - """The status code returned from the API. This can be :py:obj:`None`.""" + """The status code returned from the API.""" def __init__(self, message: Optional[str], status: Optional[int]): self.message = message diff --git a/topgg/models.py b/topgg/models.py index 8f12d763..93b3cae7 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -25,7 +25,6 @@ from typing import Any, List, Iterable, Optional, Tuple from datetime import datetime, timezone -from urllib.parse import quote def truthy_only(value: Optional[Any], default: Any = None) -> Optional[Any]: @@ -50,14 +49,7 @@ class Voter: def __init__(self, json: dict): self.id = int(json['id']) self.name = json['username'] - - if avatar_hash := json.get('avatar'): - ext = 'gif' if avatar_hash.startswith('a_') else 'png' - self.avatar = ( - f'https://cdn.discordapp.com/avatars/{self.id}/{avatar_hash}.{ext}?size=1024' - ) - else: - self.avatar = f'https://cdn.discordapp.com/embed/avatars/{(self.id >> 2) % 6}.png' + self.avatar = json['avatar'] @property def created_at(self) -> datetime: @@ -117,7 +109,7 @@ class Bot: """This bot's GitHub repository URL.""" owners: List[int] - """A list of this bot's owners IDs.""" + """This bot's owners IDs.""" banner_url: Optional[str] """This bot's banner URL.""" @@ -132,10 +124,10 @@ class Bot: """The amount of votes this bot has this month.""" support: Optional[str] - """This bot's support server invite URL.""" + """This bot's support URL.""" avatar: str - """This bot's avatar URL. Its format will either be PNG or GIF if animated.""" + """This bot's avatar URL.""" url: str """This bot's Top.gg page URL.""" @@ -155,21 +147,9 @@ def __init__(self, json: dict): self.approved_at = datetime.fromisoformat(json['date'].replace('Z', '+00:00')) self.votes = json['points'] self.monthly_votes = json['monthlyPoints'] - - if support := json.get('support'): - self.support = f'https://discord.com/invite/{support}' - else: - self.support = None - - if avatar_hash := json.get('avatar'): - ext = 'gif' if avatar_hash.startswith('a_') else 'png' - self.avatar = ( - f'https://cdn.discordapp.com/avatars/{self.id}/{avatar_hash}.{ext}?size=1024' - ) - else: - self.avatar = f'https://cdn.discordapp.com/embed/avatars/{(self.id >> 2) % 6}.png' - - self.url = f'https://top.gg/bot/{json.get("support") or self.id}' + self.support = json.get('support') + self.avatar = json['avatar'] + self.url = f'https://top.gg/bot/{json.get("vanity") or self.id}' @property def created_at(self) -> datetime: @@ -343,13 +323,11 @@ async def send(self) -> Iterable[Bot]: """ params = self.__params.copy() - params['search'] = [f'{k}%3A%20{quote(v)}' for k, v in self.__search.values()].join( - '%20' - ) + params['search'] = ' '.join(f'{k}: {v}' for k, v in self.__search.items()) if self.__sort: params['sort'] = self.__sort - bots = (await self.__client.__request('GET', '/bots', params=params)) or {} + bots = (await self.__client._Client__request('GET', '/bots', params=params)) or {} return map(Bot, bots.get('results', ())) From 55a752c96803d8622ed667dbafcd7e564cd1546e Mon Sep 17 00:00:00 2001 From: null8626 Date: Sun, 23 Feb 2025 23:37:29 +0700 Subject: [PATCH 078/126] refactor: show token length --- docs/conf.py | 2 +- docs/support-server.rst | 2 +- topgg/client.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index fe4faef9..6615edfd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,7 +25,7 @@ } redirects = { - 'support-server': 'https://discord.com/invite/dbl', + 'support-server': 'https://discord.gg/dbl', 'repository': 'https://github.com/top-gg-community/python-sdk', 'raw-api-reference': 'https://docs.top.gg/docs/', } diff --git a/docs/support-server.rst b/docs/support-server.rst index 008c5a1d..531270f0 100644 --- a/docs/support-server.rst +++ b/docs/support-server.rst @@ -2,4 +2,4 @@ Support server ============== -You should be redirected in a few moments. Otherwise, click here: https://discord.com/invite/dbl \ No newline at end of file +You should be redirected in a few moments. Otherwise, click here: https://discord.gg/dbl \ No newline at end of file diff --git a/topgg/client.py b/topgg/client.py index 099dbf7f..af3351ae 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -78,7 +78,7 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): self.id = int(loads(b64decode(encoded_json))['id']) except: - raise ValueError('Got a malformed Top.gg API token.') + raise ValueError(f'Got a malformed Top.gg API token with a length of {len(token)}.') endpoint_ratelimits = namedtuple('EndpointRatelimits', 'global_ bot') From c5f90300427e5680a390f4c0163ad28a6c0d1ef6 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 24 Feb 2025 00:19:56 +0700 Subject: [PATCH 079/126] feat: add __repr__ implementations --- topgg/client.py | 7 +++++-- topgg/errors.py | 2 +- topgg/models.py | 37 ++++++++++++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index af3351ae..4a844780 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -63,7 +63,7 @@ class Client: ) def __init__(self, token: str, *, session: Optional[ClientSession] = None): - if not isinstance(token, str): + if not isinstance(token, str) or not token: raise TypeError('An API token is required to use this API.') self.__own_session = session is None @@ -78,7 +78,7 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): self.id = int(loads(b64decode(encoded_json))['id']) except: - raise ValueError(f'Got a malformed Top.gg API token with a length of {len(token)}.') + raise ValueError('Got a malformed Top.gg API token.') endpoint_ratelimits = namedtuple('EndpointRatelimits', 'global_ bot') @@ -88,6 +88,9 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): self.__ratelimiter_manager = RatelimiterManager(self.__ratelimiters) self.__current_ratelimit = None + def __repr__(self) -> str: + return f'<{__class__.__name__} {self.__session!r}>' + async def __request( self, method: str, diff --git a/topgg/errors.py b/topgg/errors.py index 3638ce9a..62de88a7 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -59,7 +59,7 @@ class Ratelimited(Error): __slots__: Tuple[str, ...] = ('retry_after',) retry_after: float - """How long the client should wait until it can make a request to the API again.""" + """How long the client should wait (in seconds) until it can make a request to the API again.""" def __init__(self, retry_after: float): self.retry_after = retry_after diff --git a/topgg/models.py b/topgg/models.py index 93b3cae7..4a1bb833 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -44,13 +44,25 @@ class Voter: """This voter's username.""" avatar: str - """This voter's avatar URL. Its format will either be PNG or GIF if animated.""" + """This voter's avatar URL.""" def __init__(self, json: dict): self.id = int(json['id']) self.name = json['username'] self.avatar = json['avatar'] + def __repr__(self) -> str: + return f'<{__class__.__name__} id={self.id} name={self.name!r}>' + + def __int__(self) -> int: + return self.id + + def __eq__(self, other: 'Voter') -> bool: + if isinstance(other, __class__): + return self.id == other.id + + return NotImplemented + @property def created_at(self) -> datetime: """This voter's creation date.""" @@ -132,6 +144,12 @@ class Bot: url: str """This bot's Top.gg page URL.""" + invite: Optional[str] + """This bot's invite URL.""" + + server_count: Optional[str] + """This bot's posted server count.""" + def __init__(self, json: dict): self.id = int(json['clientid']) self.topgg_id = int(json['id']) @@ -150,6 +168,20 @@ def __init__(self, json: dict): self.support = json.get('support') self.avatar = json['avatar'] self.url = f'https://top.gg/bot/{json.get("vanity") or self.id}' + self.invite = json.get('invite') + self.server_count = json.get('server_count') + + def __repr__(self) -> str: + return f'<{__class__.__name__} id={self.id} name={self.name!r} votes={self.votes} monthly_votes={self.monthly_votes} server_count={self.server_count}>' + + def __int__(self) -> int: + return self.id + + def __eq__(self, other: 'Bot') -> bool: + if isinstance(other, __class__): + return self.id == other.id + + return NotImplemented @property def created_at(self) -> datetime: @@ -169,6 +201,9 @@ def __init__(self, client: object): self.__search = {} self.__sort = None + def __repr__(self) -> str: + return f'<{__class__.__name__}>' + def sort_by_id(self) -> 'BotQuery': """ Sorts results based on each bot's ID. From 93e1eb59729aeb834435c353b779d365de1d974b Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 24 Feb 2025 01:04:02 +0700 Subject: [PATCH 080/126] doc: update example comments --- README.md | 17 +++++++++-------- docs/index.rst | 23 ++++++++++++----------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index fcdb071c..017244c7 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ pip install topggpy For more information, please read the [documentation](https://topggpy.readthedocs.io/en/latest/). ```py -# import the module +# Import the module. import topgg import asyncio @@ -26,14 +26,14 @@ import os async def main() -> None: - # Declare the client. to retrieve your top.gg token, see https://docs.top.gg/docs/API/@reference. + # Declare the client. To retrieve your top.gg token, see https://docs.top.gg/docs/API/@reference. async with topgg.Client(os.getenv('TOPGG_TOKEN')) as tg: - # Fetch a Discord bot from its ID. + # Fetch a bot from its ID. bot = await tg.get_bot(432610292342587392) print(bot) - # Fetch Discord bots that matches the specified query. + # Fetch bots that matches the specified query. bots = ( await tg.get_bots() .limit(250) @@ -46,19 +46,19 @@ async def main() -> None: for b in bots: print(b) - # Post your Discord bot's server count to the API. This will update the server count in your bot's Top.gg page. + # Post your bot's server count to the API. This will update the server count in your bot's Top.gg page. await tg.post_server_count(2) - # Fetch your Discord bot's posted server count. + # Fetch your bot's posted server count. posted_server_count = await tg.get_server_count() - # Fetch your Discord bot's last 1000 voters. + # Fetch your bot's last 1000 voters. voters = await tg.get_voters() for voter in voters: print(voter) - # Check if a Discord user has voted your Discord bot. + # Check if a user has voted your bot. has_voted = await tg.has_voted(661200758510977084) if has_voted: @@ -72,6 +72,7 @@ async def main() -> None: if __name__ == '__main__': + # See https://stackoverflow.com/questions/45600579/asyncio-event-loop-is-closed-when-getting-loop # for more details. if os.name == 'nt': diff --git a/docs/index.rst b/docs/index.rst index 00c33369..32d179f4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -24,23 +24,23 @@ Example .. code-block:: python - # Import the module + # Import the module. import topgg import asyncio import os - + async def main() -> None: - - # Declare the client. to retrieve your top.gg token, see https://docs.top.gg/docs/API/@reference. + + # Declare the client. To retrieve your top.gg token, see https://docs.top.gg/docs/API/@reference. async with topgg.Client(os.getenv('TOPGG_TOKEN')) as tg: - # Fetch a Discord bot from its ID. + # Fetch a bot from its ID. bot = await tg.get_bot(432610292342587392) - + print(bot) - # Fetch Discord bots that matches the specified query. + # Fetch bots that matches the specified query. bots = ( await tg.get_bots() .limit(250) @@ -53,19 +53,19 @@ Example for b in bots: print(b) - # Post your Discord bot's server count to the API. This will update the server count in your bot's Top.gg page. + # Post your bot's server count to the API. This will update the server count in your bot's Top.gg page. await tg.post_server_count(2) - # Fetch your Discord bot's posted server count. + # Fetch your bot's posted server count. posted_server_count = await tg.get_server_count() - # Fetch your Discord bot's last 1000 voters. + # Fetch your bot's last 1000 voters. voters = await tg.get_voters() for voter in voters: print(voter) - # Check if a Discord user has voted your Discord bot. + # Check if a user has voted your bot. has_voted = await tg.has_voted(661200758510977084) if has_voted: @@ -79,6 +79,7 @@ Example if __name__ == '__main__': + # See https://stackoverflow.com/questions/45600579/asyncio-event-loop-is-closed-when-getting-loop # for more details. if os.name == 'nt': From 9041479c735bdd705f4c73adf36ba642b224b7da Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 24 Feb 2025 01:12:47 +0700 Subject: [PATCH 081/126] fix: fix potential TypeError --- topgg/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/topgg/client.py b/topgg/client.py index 4a844780..5d6b3f64 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -164,7 +164,7 @@ async def __request( return await self.__request(method, path) - raise RequestError(json.get('message'), status) from None + raise RequestError(json and json.get('message'), status) from None async def get_bot(self, id: int) -> Optional[Bot]: """ From d3bab1d99179370c66c68aef77396c611c06f1fd Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 24 Feb 2025 19:58:17 +0700 Subject: [PATCH 082/126] feat: add autoposting --- README.md | 51 ++++++++++++- docs/client.rst | 11 ++- docs/index.rst | 54 +++++++++++++- topgg/client.py | 169 +++++++++++++++++++++++++++++++++++++++++-- topgg/errors.py | 8 +- topgg/models.py | 22 +++--- topgg/ratelimiter.py | 10 +-- 7 files changed, 297 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 017244c7..7e684244 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The community-maintained Python API wrapper for [Top.gg](https://top.gg/) pip install topggpy ``` -## Example +## Basic example For more information, please read the [documentation](https://topggpy.readthedocs.io/en/latest/). @@ -28,6 +28,7 @@ async def main() -> None: # Declare the client. To retrieve your top.gg token, see https://docs.top.gg/docs/API/@reference. async with topgg.Client(os.getenv('TOPGG_TOKEN')) as tg: + # Fetch a bot from its ID. bot = await tg.get_bot(432610292342587392) @@ -71,6 +72,54 @@ async def main() -> None: print('The weekend multiplier is active!') +if __name__ == '__main__': + + # See https://stackoverflow.com/questions/45600579/asyncio-event-loop-is-closed-when-getting-loop + # for more details. + if os.name == 'nt': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + asyncio.run(main()) +``` + +# Autoposting example + +```py +# Import the module. +import topgg + +import asyncio +import os + + +async def main() -> None: + + # Declare the client. To retrieve your top.gg token, see https://docs.top.gg/docs/API/@reference. + tg = topgg.Client(os.getenv('TOPGG_TOKEN')) + + # Callback to retrieve server count data (required). + @tg.autopost_retrieval + def get_server_count() -> int: + return 2 + + # Callback upon successful server count autoposting (optional). + @tg.autopost_success + def success(server_count: int): + print(f'Successfully posted {server_count} servers to the Top.gg API!') + + # Error handler upon HTTP-related posting failure (optional). + @tg.autopost_error + def error(error: topgg.Error): + print(f'Error: {error!r}') + + # Start the autoposter. + tg.start_autoposter() + + # Your other logic here... + + # Client session cleanup while also implicitly calling tg.stop_autoposter(). + await tg.close() + if __name__ == '__main__': # See https://stackoverflow.com/questions/45600579/asyncio-event-loop-is-closed-when-getting-loop diff --git a/docs/client.rst b/docs/client.rst index e6ad15ca..b38f93c3 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -10,4 +10,13 @@ Client reference :members: .. autoclass:: topgg.errors.Ratelimited() - :members: \ No newline at end of file + :members: + +.. autodata:: topgg.client.AutopostRetrievalCallback +.. autodata:: topgg.client.AutopostRetrievalDecorator + +.. autodata:: topgg.client.AutopostSuccessCallback +.. autodata:: topgg.client.AutopostSuccessDecorator + +.. autodata:: topgg.client.AutopostErrorCallback +.. autodata:: topgg.client.AutopostErrorDecorator \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 32d179f4..a3b47ce2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,8 +19,8 @@ Installation $ pip install topggpy -Example -------- +Basic example +------------- .. code-block:: python @@ -35,6 +35,7 @@ Example # Declare the client. To retrieve your top.gg token, see https://docs.top.gg/docs/API/@reference. async with topgg.Client(os.getenv('TOPGG_TOKEN')) as tg: + # Fetch a bot from its ID. bot = await tg.get_bot(432610292342587392) @@ -87,6 +88,55 @@ Example asyncio.run(main()) +Autoposting example +------------------- + +.. code-block:: python + + # Import the module. + import topgg + + import asyncio + import os + + + async def main() -> None: + + # Declare the client. To retrieve your top.gg token, see https://docs.top.gg/docs/API/@reference. + tg = topgg.Client(os.getenv('TOPGG_TOKEN')) + + # Callback to retrieve server count data (required). + @tg.autopost_retrieval + def get_server_count() -> int: + return 2 + + # Callback upon successful server count autoposting (optional). + @tg.autopost_success + def success(server_count: int): + print(f'Successfully posted {server_count} servers to the Top.gg API!') + + # Error handler upon HTTP-related posting failure (optional). + @tg.autopost_error + def error(error: topgg.Error): + print(f'Error: {error!r}') + + # Start the autoposter. + tg.start_autoposter() + + # Your other logic here... + + # Client session cleanup while also implicitly calling tg.stop_autoposter(). + await tg.close() + + if __name__ == '__main__': + + # See https://stackoverflow.com/questions/45600579/asyncio-event-loop-is-closed-when-getting-loop + # for more details. + if os.name == 'nt': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + asyncio.run(main()) + .. toctree:: :maxdepth: 2 :hidden: diff --git a/topgg/client.py b/topgg/client.py index 5d6b3f64..eda9cb48 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -23,22 +23,37 @@ SOFTWARE. """ +from collections.abc import Iterable, Callable, Coroutine +from typing import Any, Optional, TypeVar, Union from aiohttp import ClientSession, ClientTimeout -from typing import Optional, Tuple, Iterable from collections import namedtuple +from inspect import isawaitable from base64 import b64decode -from asyncio import sleep from json import loads from time import time +import asyncio -from .models import Bot, BotQuery, Voter -from .errors import Error, RequestError, Ratelimited from .ratelimiter import Ratelimiter, RatelimiterManager +from .errors import Error, RequestError, Ratelimited +from .models import Bot, BotQuery, Voter + BASE_URL = 'https://top.gg/api' MAXIMUM_DELAY_THRESHOLD = 5.0 +AutopostRetrievalCallback = Callable[[], Union[int, Coroutine[None, None, int]]] +AutopostRetrievalDecorator = Callable[ + [AutopostRetrievalCallback], AutopostRetrievalCallback +] + +AutopostSuccessCallback = Callable[[int], Any] +AutopostSuccessDecorator = Callable[[AutopostSuccessCallback], AutopostSuccessCallback] + +AutopostErrorCallback = Callable[[Error], Any] +AutopostErrorDecorator = Callable[[AutopostErrorCallback], AutopostErrorCallback] + + class Client: """ The class that lets you interact with the API. @@ -52,13 +67,17 @@ class Client: :exception ValueError: If ``token`` is not a valid API token. """ - __slots__: Tuple[str, ...] = ( + __slots__: tuple[str, ...] = ( '__own_session', '__session', '__token', '__ratelimiters', '__ratelimiter_manager', '__current_ratelimit', + '__autopost_task', + '__autopost_retrieval_callback', + '__autopost_success_callbacks', + '__autopost_error_callbacks', 'id', ) @@ -88,6 +107,11 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): self.__ratelimiter_manager = RatelimiterManager(self.__ratelimiters) self.__current_ratelimit = None + self.__autopost_task = None + self.__autopost_retrieval_callback = None + self.__autopost_success_callbacks = set() + self.__autopost_error_callbacks = set() + def __repr__(self) -> str: return f'<{__class__.__name__} {self.__session!r}>' @@ -160,7 +184,7 @@ async def __request( raise Ratelimited(retry_after) from None - await sleep(retry_after) + await asyncio.sleep(retry_after) return await self.__request(method, path) @@ -277,9 +301,142 @@ async def has_voted(self, id: int) -> bool: return bool(response['voted']) + async def __autopost_loop(self, interval: Optional[float]) -> None: + interval = max(interval or 900.0, 900.0) + + while True: + try: + server_count = self.__autopost_retrieval_callback() + + if isawaitable(server_count): + server_count = await server_count + + await self.post_server_count(server_count) + + for success_callback in self.__autopost_success_callbacks: + success_callback_result = success_callback(server_count) + + if isawaitable(success_callback_result): + await success_callback_result + + await asyncio.sleep(interval) + except Exception as err: + if isinstance(err, Error): + for error_callback in self.__autopost_error_callbacks: + error_callback_result = error_callback(err) + + if isawaitable(error_callback_result): + await error_callback_result + elif isinstance(err, asyncio.CancelledError): + return + else: + raise + + def autopost_retrieval( + self, callback: Optional[AutopostRetrievalCallback] + ) -> Union['Client', AutopostRetrievalDecorator]: + """ + Registers an autopost server count retrieval callback. This callback is required for autoposting. + + :param callback: The autopost server count retrieval callback. This can be asynchronous or synchronous, as long as it eventually returns an :py:class:`int`. + :type callback: Optional[:data:`~.client.AutopostRetrievalCallback`] + + :returns: Either the client object itself or the function callback decorator depending on the argument. + :rtype: Union[:class:`~.Client`, :data:`~.client.AutopostRetrievalDecorator`] + """ + + if callback is not None: + self.__autopost_retrieval_callback = callback + + return self + + def decorator(callback: AutopostRetrievalCallback) -> AutopostRetrievalCallback: + self.__autopost_retrieval_callback = callback + + return callback + + return decorator + + def autopost_success( + self, callback: Optional[AutopostSuccessCallback] + ) -> Union['Client', AutopostSuccessDecorator]: + """ + Adds an autopost on success callback. Several callbacks are possible. + + :param callback: The autopost on success callback. This can be asynchronous or synchronous, as long as it accepts a :py:class:`int` argument for the posted server count. + :type callback: Optional[:data:`~.client.AutopostSuccessCallback`] + + :returns: Either the client object itself or the function callback decorator depending on the argument. + :rtype: Union[:class:`~.Client`, :data:`~.client.AutopostSuccessDecorator`] + """ + + if callback is not None: + self.__autopost_success_callbacks.add(callback) + + return self + + def decorator(callback: AutopostSuccessCallback) -> AutopostSuccessCallback: + self.__autopost_success_callbacks.add(callback) + + return callback + + return decorator + + def autopost_error( + self, callback: Optional[AutopostErrorCallback] + ) -> Union['Client', AutopostErrorDecorator]: + """ + Adds an autopost on error handler. Several callbacks are possible. + + :param callback: The autopost on error handler. This can be asynchronous or synchronous, as long as it accepts an :class:`~.Error` argument for the request exception. + :type callback: Optional[:data:`~.client.AutopostErrorCallback`] + + :returns: Either the client object itself or the function callback decorator depending on the argument. + :rtype: Union[:class:`~.Client`, :data:`~.client.AutopostErrorDecorator`] + """ + + if callback is not None: + self.__autopost_error_callbacks.add(callback) + + return self + + def decorator(callback: AutopostErrorCallback) -> AutopostErrorCallback: + self.__autopost_error_callbacks.add(callback) + + return callback + + return decorator + + def start_autoposter(self, interval: Optional[float] = None) -> None: + """ + Starts the autoposter. Has no effect if the autoposter is already running. + + :param interval: The interval between posting in seconds. Defaults to 15 minutes. + :type interval: Optional[:py:class:`float`] + + :exception Error: If the autoposter server count retrieval callback does not exist. + """ + + if self.__autopost_task is None: + if self.__autopost_retrieval_callback is None: + raise Error('Missing autopost_retrieval callback.') + + self.__autopost_task = asyncio.create_task(self.__autopost_loop(interval)) + + def stop_autoposter(self) -> None: + """ + Stops the autoposter. Has no effect if the autoposter is already stopped. + """ + + if self.__autopost_task is not None: + self.__autopost_task.cancel() + self.__autopost_task = None + async def close(self) -> None: """Closes the :class:`~.client.Client` object. Nothing will happen if the client uses a pre-existing :class:`~aiohttp.ClientSession` or if the session is already closed.""" + self.stop_autoposter() + if self.__own_session and not self.__session.closed: await self.__session.close() diff --git a/topgg/errors.py b/topgg/errors.py index 62de88a7..1d7350df 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -23,19 +23,19 @@ SOFTWARE. """ -from typing import Tuple, Optional +from typing import Optional class Error(Exception): """The base error class. Extends :py:class:`Exception`.""" - __slots__: Tuple[str, ...] = () + __slots__: tuple[str, ...] = () class RequestError(Error): """Thrown upon HTTP request failure. Extends :class:`~.errors.Error`.""" - __slots__: Tuple[str, ...] = ('message', 'status') + __slots__: tuple[str, ...] = ('message', 'status') message: Optional[str] """The message returned from the API.""" @@ -56,7 +56,7 @@ def __repr__(self) -> str: class Ratelimited(Error): """Thrown upon HTTP request failure due to the client being ratelimited. Because of this, the client is not allowed to make requests for an hour. Extends :class:`~.errors.Error`.""" - __slots__: Tuple[str, ...] = ('retry_after',) + __slots__: tuple[str, ...] = ('retry_after',) retry_after: float """How long the client should wait (in seconds) until it can make a request to the API again.""" diff --git a/topgg/models.py b/topgg/models.py index 4a1bb833..645913bd 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -23,19 +23,21 @@ SOFTWARE. """ -from typing import Any, List, Iterable, Optional, Tuple from datetime import datetime, timezone +from typing import Optional, TypeVar +from collections.abc import Iterable -def truthy_only(value: Optional[Any], default: Any = None) -> Optional[Any]: +T = TypeVar('T') + + +def truthy_only(value: Optional[T]) -> Optional[T]: if value: return value - return default - class Voter: - __slots__: Tuple[str, ...] = ('id', 'name', 'avatar') + __slots__: tuple[str, ...] = ('id', 'name', 'avatar') id: int """This voter's user ID.""" @@ -73,7 +75,7 @@ def created_at(self) -> datetime: class Bot: - __slots__: Tuple[str, ...] = ( + __slots__: tuple[str, ...] = ( 'id', 'topgg_id', 'name', @@ -91,6 +93,8 @@ class Bot: 'support', 'avatar', 'url', + 'invite', + 'server_count', ) id: int @@ -111,7 +115,7 @@ class Bot: long_description: Optional[str] """This bot's long description. This can contain HTML and/or Markdown.""" - tags: List[str] + tags: list[str] """This bot's tags.""" website: Optional[str] @@ -120,7 +124,7 @@ class Bot: github: Optional[str] """This bot's GitHub repository URL.""" - owners: List[int] + owners: list[int] """This bot's owners IDs.""" banner_url: Optional[str] @@ -193,7 +197,7 @@ def created_at(self) -> datetime: class BotQuery: - __slots__: Tuple[str, ...] = ('__client', '__params', '__search', '__sort') + __slots__: tuple[str, ...] = ('__client', '__params', '__search', '__sort') def __init__(self, client: object): self.__client = client diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index 2984c05d..a7cb7e5d 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -23,7 +23,7 @@ SOFTWARE. """ -from typing import Type, Tuple, Iterable +from collections.abc import Iterable from types import TracebackType from collections import deque from time import time @@ -31,7 +31,7 @@ class Ratelimiter: - __slots__: Tuple[str, ...] = ('__lock', '__max_calls', '__period', '__calls') + __slots__: tuple[str, ...] = ('__lock', '__max_calls', '__period', '__calls') def __init__( self, @@ -55,7 +55,7 @@ async def __aenter__(self) -> 'Ratelimiter': async def __aexit__( self, - _exc_type: Type[BaseException], + _exc_type: type[BaseException], _exc_val: BaseException, _exc_tb: TracebackType, ) -> None: @@ -72,7 +72,7 @@ def _timespan(self) -> float: class RatelimiterManager: - __slots__: Tuple[str, ...] = ('__ratelimiters',) + __slots__: tuple[str, ...] = ('__ratelimiters',) def __init__(self, ratelimiters: Iterable[Ratelimiter]): self.__ratelimiters = ratelimiters @@ -85,7 +85,7 @@ async def __aenter__(self) -> 'RatelimiterManager': async def __aexit__( self, - exc_type: Type[BaseException], + exc_type: type[BaseException], exc_val: BaseException, exc_tb: TracebackType, ) -> None: From 2caee048c98362d7c082e79711074ab23ce95016 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 24 Feb 2025 20:56:46 +0700 Subject: [PATCH 083/126] feat: add version.py and test_autoposter.py --- .github/workflows/test.yml | 34 ------------------------- MANIFEST.in | 1 + test_autoposter.py | 51 ++++++++++++++++++++++++++++++++++++++ topgg/__init__.py | 4 ++- topgg/client.py | 5 ++-- topgg/version.py | 1 + 6 files changed, 59 insertions(+), 37 deletions(-) delete mode 100644 .github/workflows/test.yml create mode 100644 test_autoposter.py create mode 100644 topgg/version.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 85c4a198..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Run tests -on: - push: - branches: [master, v0-rewrite] - paths: - - '.github/workflows/test.yml' - - 'topgg/**/*.py' - pull_request: - paths: - - '.github/workflows/test.yml' - - 'topgg/**/*.py' -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.9, '3.10', 3.11, 3.12, 3.13] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: python3 -m pip install . - - name: Run tests - run: python3 test.py - env: - TOPGG_TOKEN: ${{ secrets.TOPGG_TOKEN }} - - name: Install dev dependencies - if: ${{ matrix.python-version == 3.13 }} - run: python3 -m pip install ruff - - name: Lint with ruff - if: ${{ matrix.python-version == 3.13 }} - run: python3 -m ruff check diff --git a/MANIFEST.in b/MANIFEST.in index 0d704918..d68037a4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,4 +6,5 @@ exclude .gitignore exclude .readthedocs.yml exclude ruff.toml exclude test.py +exclude test_autoposter.py exclude LICENSE \ No newline at end of file diff --git a/test_autoposter.py b/test_autoposter.py new file mode 100644 index 00000000..d24b837c --- /dev/null +++ b/test_autoposter.py @@ -0,0 +1,51 @@ +ORIGINAL_INTERVAL_INSTRUCTION = 'interval = max(interval or 900.0, 900.0)' +MODIFIED_INTERVAL_INSTRUCTION = 'interval = interval' + + +def replace_client_file(former: str, latter: str) -> None: + client_file_contents = None + + with open('./topgg/client.py', 'r') as client_file: + client_file_contents = client_file.read().replace(former, latter) + + with open('./topgg/client.py', 'w') as client_file: + client_file.write(client_file_contents) + + +replace_client_file(ORIGINAL_INTERVAL_INSTRUCTION, MODIFIED_INTERVAL_INSTRUCTION) + + +import topgg + +import asyncio +import os + + +async def run() -> None: + try: + async with topgg.Client(os.getenv('TOPGG_TOKEN')) as tg: + + @tg.autopost_retrieval + def get_server_count() -> int: + return 2 + + @tg.autopost_success + def success(server_count: int): + print(f'Successfully posted {server_count} servers to the Top.gg API!') + + @tg.autopost_error + def error(error: topgg.Error): + print(f'Error: {error!r}') + + tg.start_autoposter(5.0) + + await asyncio.sleep(15) + finally: + replace_client_file(MODIFIED_INTERVAL_INSTRUCTION, ORIGINAL_INTERVAL_INSTRUCTION) + + +if __name__ == '__main__': + if os.name == 'nt': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + asyncio.run(run()) diff --git a/topgg/__init__.py b/topgg/__init__.py index b7a937d3..6cef39c0 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -25,11 +25,13 @@ from .errors import Error, RequestError, Ratelimited from .models import Bot, BotQuery, Voter +from .version import VERSION from .client import Client + __title__ = 'topggpy' __author__ = 'null8626 & Top.gg' __license__ = 'MIT' __copyright__ = 'Copyright (c) 2021 Assanali Mukhanov & Top.gg; Copyright (c) 2024-2025 null8626 & Top.gg' -__version__ = '3.0.0' +__version__ = VERSION __all__ = ('Bot', 'BotQuery', 'Client', 'Error', 'Ratelimited', 'RequestError', 'Voter') diff --git a/topgg/client.py b/topgg/client.py index eda9cb48..23983121 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -24,8 +24,8 @@ """ from collections.abc import Iterable, Callable, Coroutine -from typing import Any, Optional, TypeVar, Union from aiohttp import ClientSession, ClientTimeout +from typing import Any, Optional, Union from collections import namedtuple from inspect import isawaitable from base64 import b64decode @@ -36,6 +36,7 @@ from .ratelimiter import Ratelimiter, RatelimiterManager from .errors import Error, RequestError, Ratelimited from .models import Bot, BotQuery, Voter +from .version import VERSION BASE_URL = 'https://top.gg/api' @@ -160,7 +161,7 @@ async def __request( headers={ 'Authorization': self.__token, 'Content-Type': 'application/json', - 'User-Agent': 'topggpy (https://github.com/top-gg-community/python-sdk 3.0.0) Python/', + 'User-Agent': f'topggpy (https://github.com/top-gg-community/python-sdk {VERSION}) Python/', }, **kwargs, ) as resp: diff --git a/topgg/version.py b/topgg/version.py new file mode 100644 index 00000000..aaa42644 --- /dev/null +++ b/topgg/version.py @@ -0,0 +1 @@ +VERSION = '3.0.0' From df885d747430977e2351e03801710b5d570eec8c Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 24 Feb 2025 21:20:09 +0700 Subject: [PATCH 084/126] feat: add dependabot --- .github/dependabot.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..b100e72b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: / + commit-message: + prefix: 'deps: ' + schedule: + day: saturday + interval: weekly + time: '06:00' \ No newline at end of file From a9a443ab3a97d257a8ccdb8d02531d46b557bba6 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Mon, 24 Feb 2025 21:52:37 +0700 Subject: [PATCH 085/126] Update models.py --- topgg/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/topgg/models.py b/topgg/models.py index 645913bd..08380682 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -131,7 +131,7 @@ class Bot: """This bot's banner URL.""" approved_at: datetime - """The date when this bot was approved on Top.gg.""" + """When this bot was approved on Top.gg.""" votes: int """The amount of votes this bot has.""" From 9146924245bfa8ea95b609ff4167b1440df2beee Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 25 Feb 2025 00:53:58 +0700 Subject: [PATCH 086/126] doc: add warning [skip ci] --- topgg/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/topgg/client.py b/topgg/client.py index 23983121..d5d9be72 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -303,6 +303,7 @@ async def has_voted(self, id: int) -> bool: return bool(response['voted']) async def __autopost_loop(self, interval: Optional[float]) -> None: + # The following line should not be changed, as it could affect test_autoposter.py. interval = max(interval or 900.0, 900.0) while True: From 45308c85748e52bbd90abd20e48ab6eb5da4b021 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 25 Feb 2025 14:27:09 +0700 Subject: [PATCH 087/126] fix: adapt more stuff for v0 --- README.md | 8 ++++---- docs/index.rst | 8 ++++---- test.py | 2 +- test_autoposter.py | 4 ++-- topgg/client.py | 15 +++++++++++---- topgg/models.py | 30 +++++++++++++++--------------- 6 files changed, 37 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 7e684244..f8d8fcd9 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ async def main() -> None: bots = ( await tg.get_bots() .limit(250) - .skip(50) + .offset(50) .name('shiro') .sort_by_monthly_votes() .send() @@ -53,7 +53,7 @@ async def main() -> None: # Fetch your bot's posted server count. posted_server_count = await tg.get_server_count() - # Fetch your bot's last 1000 voters. + # Fetch your bot's last 1000 unique voters. voters = await tg.get_voters() for voter in voters: @@ -104,12 +104,12 @@ async def main() -> None: # Callback upon successful server count autoposting (optional). @tg.autopost_success - def success(server_count: int): + def success(server_count: int) -> None: print(f'Successfully posted {server_count} servers to the Top.gg API!') # Error handler upon HTTP-related posting failure (optional). @tg.autopost_error - def error(error: topgg.Error): + def error(error: topgg.Error) -> None: print(f'Error: {error!r}') # Start the autoposter. diff --git a/docs/index.rst b/docs/index.rst index a3b47ce2..701cb715 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -45,7 +45,7 @@ Basic example bots = ( await tg.get_bots() .limit(250) - .skip(50) + .offset(50) .name('shiro') .sort_by_monthly_votes() .send() @@ -60,7 +60,7 @@ Basic example # Fetch your bot's posted server count. posted_server_count = await tg.get_server_count() - # Fetch your bot's last 1000 voters. + # Fetch your bot's last 1000 unique voters. voters = await tg.get_voters() for voter in voters: @@ -112,12 +112,12 @@ Autoposting example # Callback upon successful server count autoposting (optional). @tg.autopost_success - def success(server_count: int): + def success(server_count: int) -> None: print(f'Successfully posted {server_count} servers to the Top.gg API!') # Error handler upon HTTP-related posting failure (optional). @tg.autopost_error - def error(error: topgg.Error): + def error(error: topgg.Error) -> None: print(f'Error: {error!r}') # Start the autoposter. diff --git a/test.py b/test.py index e6b1997f..6c897515 100644 --- a/test.py +++ b/test.py @@ -49,7 +49,7 @@ async def run() -> None: bots = ( await tg.get_bots() .limit(250) - .skip(50) + .offset(50) .name('shiro') .sort_by_monthly_votes() .send() diff --git a/test_autoposter.py b/test_autoposter.py index d24b837c..e9b41874 100644 --- a/test_autoposter.py +++ b/test_autoposter.py @@ -30,11 +30,11 @@ def get_server_count() -> int: return 2 @tg.autopost_success - def success(server_count: int): + def success(server_count: int) -> None: print(f'Successfully posted {server_count} servers to the Top.gg API!') @tg.autopost_error - def error(error: topgg.Error): + def error(error: topgg.Error) -> None: print(f'Error: {error!r}') tg.start_autoposter(5.0) diff --git a/topgg/client.py b/topgg/client.py index d5d9be72..66879e8f 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -98,7 +98,7 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): self.id = int(loads(b64decode(encoded_json))['id']) except: - raise ValueError('Got a malformed Top.gg API token.') + raise ValueError('Got a malformed API token.') endpoint_ratelimits = namedtuple('EndpointRatelimits', 'global_ bot') @@ -236,15 +236,22 @@ async def get_server_count(self) -> Optional[int]: return stats and stats.get('server_count') - async def post_server_count(self, new_server_count: int): + async def post_server_count(self, new_server_count: int) -> None: """ Posts your Discord bot's server count to the API. This will update the server count in your bot's Top.gg page. + :param new_server_count: The new server count to post. This cannot be zero. + :type new_server_count: :py:class:`int` + + :exception ValueError: If the new_server_count argument is zero or lower. :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. :exception RequestError: If the client received a non-favorable response from the API. :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. """ + if new_server_count <= 0: + raise ValueError(f'Posted server count cannot be zero or lower, got {new_server_count}.') + await self.__request( 'POST', f'/bots/{self.id}/stats', json={'server_count': new_server_count} ) @@ -267,13 +274,13 @@ async def is_weekend(self) -> bool: async def get_voters(self) -> Iterable[Voter]: """ - Fetches and yields your Discord bot's last 1000 voters. + Fetches and yields your Discord bot's last 1000 unique voters. :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. :exception RequestError: If the client received a non-favorable response from the API. :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. - :returns: Your bot's last 1000 voters. + :returns: Your bot's last 1000 unique voters. :rtype: Iterable[:class:`~.models.Voter`] """ diff --git a/topgg/models.py b/topgg/models.py index 08380682..f1ab9b70 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -40,7 +40,7 @@ class Voter: __slots__: tuple[str, ...] = ('id', 'name', 'avatar') id: int - """This voter's user ID.""" + """This voter's Discord ID.""" name: str """This voter's username.""" @@ -87,7 +87,7 @@ class Bot: 'github', 'owners', 'banner_url', - 'approved_at', + 'submitted_at', 'votes', 'monthly_votes', 'support', @@ -98,10 +98,10 @@ class Bot: ) id: int - """This bot's application ID.""" + """This bot's Discord ID.""" topgg_id: int - """This bot's Top.gg user ID.""" + """This bot's Top.gg ID.""" name: str """This bot's username.""" @@ -130,8 +130,8 @@ class Bot: banner_url: Optional[str] """This bot's banner URL.""" - approved_at: datetime - """When this bot was approved on Top.gg.""" + submitted_at: datetime + """When this bot was submitted on Top.gg.""" votes: int """The amount of votes this bot has.""" @@ -161,18 +161,18 @@ def __init__(self, json: dict): self.prefix = json['prefix'] self.short_description = json['shortdesc'] self.long_description = truthy_only(json.get('longdesc')) - self.tags = json.get('tags') or [] + self.tags = json['tags'] self.website = truthy_only(json.get('website')) self.github = truthy_only(json.get('github')) - self.owners = [int(id) for id in json.get('owners') or []] + self.owners = [int(id) for id in json['owners']] self.banner_url = truthy_only(json.get('bannerUrl')) - self.approved_at = datetime.fromisoformat(json['date'].replace('Z', '+00:00')) + self.submitted_at = datetime.fromisoformat(json['date'].replace('Z', '+00:00')) self.votes = json['points'] self.monthly_votes = json['monthlyPoints'] - self.support = json.get('support') + self.support = truthy_only(json.get('support')) self.avatar = json['avatar'] self.url = f'https://top.gg/bot/{json.get("vanity") or self.id}' - self.invite = json.get('invite') + self.invite = truthy_only(json.get('invite')) self.server_count = json.get('server_count') def __repr__(self) -> str: @@ -220,9 +220,9 @@ def sort_by_id(self) -> 'BotQuery': return self - def sort_by_approval_date(self) -> 'BotQuery': + def sort_by_submission_date(self) -> 'BotQuery': """ - Sorts results based on each bot's approval date. + Sorts results based on each bot's submission date. :returns: The same object. This allows this object to have chained method calls. :rtype: :class:`~.models.BotQuery` @@ -259,7 +259,7 @@ def limit(self, limit: int) -> 'BotQuery': return self - def skip(self, skip: int) -> 'BotQuery': + def offset(self, offset: int) -> 'BotQuery': """ Sets the amount of bots to be skipped during the query. @@ -270,7 +270,7 @@ def skip(self, skip: int) -> 'BotQuery': :rtype: :class:`~.models.BotQuery` """ - self.__params['skip'] = max(min(skip, 499), 0) + self.__params['offset'] = max(min(offset, 499), 0) return self From 7057732481bc1d8b55ba6310332ff84265cc7e9f Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:09:16 +0700 Subject: [PATCH 088/126] doc: update documentation for submitted_at --- topgg/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/topgg/models.py b/topgg/models.py index f1ab9b70..398d1362 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -131,7 +131,7 @@ class Bot: """This bot's banner URL.""" submitted_at: datetime - """When this bot was submitted on Top.gg.""" + """This bot's submission date.""" votes: int """The amount of votes this bot has.""" From de6ab6f747e5be64446e0f34799ca1943a12fa19 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 26 Feb 2025 11:22:05 +0700 Subject: [PATCH 089/126] refactor: use timestamp_from_id function --- topgg/models.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/topgg/models.py b/topgg/models.py index 398d1362..edd1bc65 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -35,6 +35,11 @@ def truthy_only(value: Optional[T]) -> Optional[T]: if value: return value +def timestamp_from_id(id: int) -> datetime: + return datetime.fromtimestamp( + ((id >> 22) + 1420070400000) // 1000, tz=timezone.utc + ) + class Voter: __slots__: tuple[str, ...] = ('id', 'name', 'avatar') @@ -69,9 +74,7 @@ def __eq__(self, other: 'Voter') -> bool: def created_at(self) -> datetime: """This voter's creation date.""" - return datetime.fromtimestamp( - ((self.id >> 22) + 1420070400000) // 1000, tz=timezone.utc - ) + return timestamp_from_id(self.id) class Bot: @@ -191,9 +194,7 @@ def __eq__(self, other: 'Bot') -> bool: def created_at(self) -> datetime: """This bot's creation date.""" - return datetime.fromtimestamp( - ((self.id >> 22) + 1420070400000) // 1000, tz=timezone.utc - ) + return timestamp_from_id(self.id) class BotQuery: From 346c4ff9fff7e917a81b4ba366fa2e2b3fd627df Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 26 Feb 2025 17:55:30 +0700 Subject: [PATCH 090/126] feat: rename this back to skip --- README.md | 2 +- docs/index.rst | 2 +- test.py | 2 +- topgg/models.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f8d8fcd9..4ebd20da 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ async def main() -> None: bots = ( await tg.get_bots() .limit(250) - .offset(50) + .skip(50) .name('shiro') .sort_by_monthly_votes() .send() diff --git a/docs/index.rst b/docs/index.rst index 701cb715..7e3d64c0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -45,7 +45,7 @@ Basic example bots = ( await tg.get_bots() .limit(250) - .offset(50) + .skip(50) .name('shiro') .sort_by_monthly_votes() .send() diff --git a/test.py b/test.py index 6c897515..e6b1997f 100644 --- a/test.py +++ b/test.py @@ -49,7 +49,7 @@ async def run() -> None: bots = ( await tg.get_bots() .limit(250) - .offset(50) + .skip(50) .name('shiro') .sort_by_monthly_votes() .send() diff --git a/topgg/models.py b/topgg/models.py index edd1bc65..3488435b 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -260,7 +260,7 @@ def limit(self, limit: int) -> 'BotQuery': return self - def offset(self, offset: int) -> 'BotQuery': + def skip(self, skip: int) -> 'BotQuery': """ Sets the amount of bots to be skipped during the query. @@ -271,7 +271,7 @@ def offset(self, offset: int) -> 'BotQuery': :rtype: :class:`~.models.BotQuery` """ - self.__params['offset'] = max(min(offset, 499), 0) + self.__params['offset'] = max(min(skip, 499), 0) return self From e1053e916c35ee3ba93f182b5e03857d82535ce7 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 26 Feb 2025 18:12:17 +0700 Subject: [PATCH 091/126] feat: add review_score and review_count --- topgg/models.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/topgg/models.py b/topgg/models.py index 3488435b..9bba6351 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -98,6 +98,8 @@ class Bot: 'url', 'invite', 'server_count', + 'review_score', + 'review_count', ) id: int @@ -157,6 +159,12 @@ class Bot: server_count: 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']) @@ -177,6 +185,8 @@ def __init__(self, json: dict): self.url = f'https://top.gg/bot/{json.get("vanity") or self.id}' self.invite = truthy_only(json.get('invite')) 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} name={self.name!r} votes={self.votes} monthly_votes={self.monthly_votes} server_count={self.server_count}>' From 2a955febc7077bf79d03bbf875ec75e3cb8587ea Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 4 Mar 2025 20:13:43 +0700 Subject: [PATCH 092/126] feat: add page to get_voters --- topgg/client.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index 66879e8f..705d5755 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -272,19 +272,22 @@ async def is_weekend(self) -> bool: return response['is_weekend'] - async def get_voters(self) -> Iterable[Voter]: + async def get_voters(self, page: int = 1) -> Iterable[Voter]: """ - Fetches and yields your Discord bot's last 1000 unique voters. + Fetches and yields your Discord bot's recent unique voters. + + :param page: The page number. Each page can only have at most 100 voters. Defaults to 1. + :type page: :py:class:`int` :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. :exception RequestError: If the client received a non-favorable response from the API. :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. - :returns: Your bot's last 1000 unique voters. + :returns: A generator of your bot's recent unique voters. :rtype: Iterable[:class:`~.models.Voter`] """ - voters = await self.__request('GET', f'/bots/{self.id}/votes') + voters = await self.__request('GET', f'/bots/{self.id}/votes', params={'page': max(page, 1)}) return map(Voter, voters or ()) From efe44c8119facb2e226f07666b288e8eb59131a7 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 5 Mar 2025 16:51:45 +0700 Subject: [PATCH 093/126] feat: add webhooks back --- README.md | 2 +- docs/conf.py | 12 +-- docs/index.rst | 3 +- docs/webhooks.rst | 11 +++ test_autoposter.py | 2 +- topgg/__init__.py | 17 +++- topgg/client.py | 68 ++++++++------- topgg/errors.py | 4 +- topgg/models.py | 13 ++- topgg/webhooks.py | 206 +++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 290 insertions(+), 48 deletions(-) create mode 100644 docs/webhooks.rst create mode 100644 topgg/webhooks.py diff --git a/README.md b/README.md index 4ebd20da..9607e961 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ async def main() -> None: # Callback upon successful server count autoposting (optional). @tg.autopost_success def success(server_count: int) -> None: - print(f'Successfully posted {server_count} servers to the Top.gg API!') + print(f'Successfully posted {server_count} servers to the API!') # Error handler upon HTTP-related posting failure (optional). @tg.autopost_error diff --git a/docs/conf.py b/docs/conf.py index 6615edfd..7fc7eee2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -2,8 +2,13 @@ import os import re + +sys.path.insert(0, os.path.join(os.getcwd(), '..', 'topgg')) sys.path.insert(0, os.path.abspath('..')) +from version import VERSION + + project = 'topggpy' author = 'null8626' @@ -11,12 +16,7 @@ with open('../LICENSE', 'r') as f: copyright = re.search(r'[\d\-]+ null8626', f.read()).group() -version = '' -with open('../topgg/__init__.py', 'r') as f: - version = re.search( - r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE - ).group(1) - +version = VERSION extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx_reredirects'] intersphinx_mapping = { diff --git a/docs/index.rst b/docs/index.rst index 7e3d64c0..7ed66fcc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -113,7 +113,7 @@ Autoposting example # Callback upon successful server count autoposting (optional). @tg.autopost_success def success(server_count: int) -> None: - print(f'Successfully posted {server_count} servers to the Top.gg API!') + print(f'Successfully posted {server_count} servers to the API!') # Error handler upon HTTP-related posting failure (optional). @tg.autopost_error @@ -143,6 +143,7 @@ Autoposting example client data + webhooks support-server repository raw-api-reference \ No newline at end of file diff --git a/docs/webhooks.rst b/docs/webhooks.rst new file mode 100644 index 00000000..9d21cdb5 --- /dev/null +++ b/docs/webhooks.rst @@ -0,0 +1,11 @@ +Webhooks reference +================== + +.. autoclass:: topgg.webhooks.Webhooks + :members: + +.. autoclass:: topgg.webhooks.Vote() + :members: + +.. autodata:: topgg.webhooks.OnVoteCallback +.. autodata:: topgg.webhooks.OnVoteDecorator \ No newline at end of file diff --git a/test_autoposter.py b/test_autoposter.py index e9b41874..520b44ea 100644 --- a/test_autoposter.py +++ b/test_autoposter.py @@ -31,7 +31,7 @@ def get_server_count() -> int: @tg.autopost_success def success(server_count: int) -> None: - print(f'Successfully posted {server_count} servers to the Top.gg API!') + print(f'Successfully posted {server_count} servers to the API!') @tg.autopost_error def error(error: topgg.Error) -> None: diff --git a/topgg/__init__.py b/topgg/__init__.py index 6cef39c0..9a9b8cf9 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -25,13 +25,28 @@ from .errors import Error, RequestError, Ratelimited from .models import Bot, BotQuery, Voter +from .webhooks import Vote, Webhooks from .version import VERSION from .client import Client __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__ = ('Bot', 'BotQuery', 'Client', 'Error', 'Ratelimited', 'RequestError', 'Voter') +__all__ = ( + 'Bot', + 'BotQuery', + 'Client', + 'Error', + 'Ratelimited', + 'RequestError', + 'VERSION', + 'Vote', + 'Voter', + 'Webhooks', +) diff --git a/topgg/client.py b/topgg/client.py index 705d5755..b5bd015f 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -57,7 +57,7 @@ class Client: """ - The class that lets you interact with the API. + Interact with the API's endpoints. :param token: The API token to use with the API. To retrieve your API token, see https://docs.top.gg/docs/API/@reference. :type token: :py:class:`str` @@ -250,7 +250,9 @@ async def post_server_count(self, new_server_count: int) -> None: """ if new_server_count <= 0: - raise ValueError(f'Posted server count cannot be zero or lower, got {new_server_count}.') + raise ValueError( + f'Posted server count cannot be zero or lower, got {new_server_count}.' + ) await self.__request( 'POST', f'/bots/{self.id}/stats', json={'server_count': new_server_count} @@ -287,7 +289,9 @@ async def get_voters(self, page: int = 1) -> Iterable[Voter]: :rtype: Iterable[:class:`~.models.Voter`] """ - voters = await self.__request('GET', f'/bots/{self.id}/votes', params={'page': max(page, 1)}) + voters = await self.__request( + 'GET', f'/bots/{self.id}/votes', params={'page': max(page, 1)} + ) return map(Voter, voters or ()) @@ -345,78 +349,78 @@ async def __autopost_loop(self, interval: Optional[float]) -> None: raise def autopost_retrieval( - self, callback: Optional[AutopostRetrievalCallback] - ) -> Union['Client', AutopostRetrievalDecorator]: + self, callback: Optional[AutopostRetrievalCallback] = None + ) -> Union[AutopostRetrievalCallback, AutopostRetrievalDecorator]: """ Registers an autopost server count retrieval callback. This callback is required for autoposting. :param callback: The autopost server count retrieval callback. This can be asynchronous or synchronous, as long as it eventually returns an :py:class:`int`. :type callback: Optional[:data:`~.client.AutopostRetrievalCallback`] - :returns: Either the client object itself or the function callback decorator depending on the argument. - :rtype: Union[:class:`~.Client`, :data:`~.client.AutopostRetrievalDecorator`] + :returns: The function itself or a decorated function depending on the argument. + :rtype: Union[:data:`~.client.AutopostRetrievalCallback`, :data:`~.client.AutopostRetrievalDecorator`] """ - if callback is not None: + def decorator(callback: AutopostRetrievalCallback) -> AutopostRetrievalCallback: self.__autopost_retrieval_callback = callback - return self + return callback - def decorator(callback: AutopostRetrievalCallback) -> AutopostRetrievalCallback: - self.__autopost_retrieval_callback = callback + if callback is not None: + decorator(callback) return callback return decorator def autopost_success( - self, callback: Optional[AutopostSuccessCallback] - ) -> Union['Client', AutopostSuccessDecorator]: + self, callback: Optional[AutopostSuccessCallback] = None + ) -> Union[AutopostSuccessCallback, AutopostSuccessDecorator]: """ Adds an autopost on success callback. Several callbacks are possible. :param callback: The autopost on success callback. This can be asynchronous or synchronous, as long as it accepts a :py:class:`int` argument for the posted server count. :type callback: Optional[:data:`~.client.AutopostSuccessCallback`] - :returns: Either the client object itself or the function callback decorator depending on the argument. - :rtype: Union[:class:`~.Client`, :data:`~.client.AutopostSuccessDecorator`] + :returns: The function itself or a decorated function depending on the argument. + :rtype: Union[:data:`~.client.AutopostSuccessCallback`, :data:`~.client.AutopostSuccessDecorator`] """ - if callback is not None: - self.__autopost_success_callbacks.add(callback) - - return self - def decorator(callback: AutopostSuccessCallback) -> AutopostSuccessCallback: self.__autopost_success_callbacks.add(callback) return callback + if callback is not None: + decorator(callback) + + return self + return decorator def autopost_error( - self, callback: Optional[AutopostErrorCallback] - ) -> Union['Client', AutopostErrorDecorator]: + self, callback: Optional[AutopostErrorCallback] = None + ) -> Union[AutopostErrorCallback, AutopostErrorDecorator]: """ Adds an autopost on error handler. Several callbacks are possible. :param callback: The autopost on error handler. This can be asynchronous or synchronous, as long as it accepts an :class:`~.Error` argument for the request exception. :type callback: Optional[:data:`~.client.AutopostErrorCallback`] - :returns: Either the client object itself or the function callback decorator depending on the argument. - :rtype: Union[:class:`~.Client`, :data:`~.client.AutopostErrorDecorator`] + :returns: The function itself or a decorated function depending on the argument. + :rtype: Union[:data:`~.client.AutopostErrorCallback`, :data:`~.client.AutopostErrorDecorator`] """ - if callback is not None: - self.__autopost_error_callbacks.add(callback) - - return self - def decorator(callback: AutopostErrorCallback) -> AutopostErrorCallback: self.__autopost_error_callbacks.add(callback) return callback + if callback is not None: + decorator + + return self + return decorator def start_autoposter(self, interval: Optional[float] = None) -> None: @@ -426,12 +430,12 @@ def start_autoposter(self, interval: Optional[float] = None) -> None: :param interval: The interval between posting in seconds. Defaults to 15 minutes. :type interval: Optional[:py:class:`float`] - :exception Error: If the autoposter server count retrieval callback does not exist. + :exception TypeError: If the autoposter server count retrieval callback does not exist. """ if self.__autopost_task is None: if self.__autopost_retrieval_callback is None: - raise Error('Missing autopost_retrieval callback.') + raise TypeError('Missing autopost_retrieval callback.') self.__autopost_task = asyncio.create_task(self.__autopost_loop(interval)) @@ -445,7 +449,7 @@ def stop_autoposter(self) -> None: self.__autopost_task = None async def close(self) -> None: - """Closes the :class:`~.client.Client` object. Nothing will happen if the client uses a pre-existing :class:`~aiohttp.ClientSession` or if the session is already closed.""" + """Closes the client. Nothing will happen if the client uses a pre-existing :class:`~aiohttp.ClientSession` or if the session is already closed.""" self.stop_autoposter() diff --git a/topgg/errors.py b/topgg/errors.py index 1d7350df..922c0c0e 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -33,7 +33,7 @@ class Error(Exception): class RequestError(Error): - """Thrown upon HTTP request failure. Extends :class:`~.errors.Error`.""" + """HTTP request failure. Extends :class:`~.errors.Error`.""" __slots__: tuple[str, ...] = ('message', 'status') @@ -54,7 +54,7 @@ def __repr__(self) -> str: class Ratelimited(Error): - """Thrown upon HTTP request failure due to the client being ratelimited. Because of this, the client is not allowed to make requests for an hour. Extends :class:`~.errors.Error`.""" + """HTTP request failure due to the client being ratelimited. Because of this, the client is not allowed to make requests for an hour. Extends :class:`~.errors.Error`.""" __slots__: tuple[str, ...] = ('retry_after',) diff --git a/topgg/models.py b/topgg/models.py index 9bba6351..8d1b8f2d 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -35,13 +35,14 @@ def truthy_only(value: Optional[T]) -> Optional[T]: if value: return value + def timestamp_from_id(id: int) -> datetime: - return datetime.fromtimestamp( - ((id >> 22) + 1420070400000) // 1000, tz=timezone.utc - ) + return datetime.fromtimestamp(((id >> 22) + 1420070400000) // 1000, tz=timezone.utc) class Voter: + """A Top.gg voter.""" + __slots__: tuple[str, ...] = ('id', 'name', 'avatar') id: int @@ -78,6 +79,8 @@ def created_at(self) -> datetime: class Bot: + """A Discord bot listed on Top.gg.""" + __slots__: tuple[str, ...] = ( 'id', 'topgg_id', @@ -182,7 +185,7 @@ def __init__(self, json: dict): self.monthly_votes = json['monthlyPoints'] self.support = truthy_only(json.get('support')) self.avatar = json['avatar'] - self.url = f'https://top.gg/bot/{json.get("vanity") or self.id}' + self.url = f'https://top.gg/bot/{json.get("vanity") or self.topgg_id}' self.invite = truthy_only(json.get('invite')) self.server_count = json.get('server_count') self.review_score = json['reviews']['averageScore'] @@ -208,6 +211,8 @@ def created_at(self) -> datetime: class BotQuery: + """Configure a query before sending it to the API.""" + __slots__: tuple[str, ...] = ('__client', '__params', '__search', '__sort') def __init__(self, client: object): diff --git a/topgg/webhooks.py b/topgg/webhooks.py new file mode 100644 index 00000000..e2a4e020 --- /dev/null +++ b/topgg/webhooks.py @@ -0,0 +1,206 @@ +""" +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 collections.abc import Awaitable, Callable +from typing import Any, Optional, Union +from inspect import isawaitable +from urllib import parse +from aiohttp import web + + +RawCallback = Callable[[web.Request], Awaitable[web.StreamResponse]] + + +class Vote: + """A dispatched Top.gg vote event.""" + + __slots__ = ('receiver_id', 'voter_id', 'is_server', 'is_test', 'is_weekend', 'query') + + receiver_id: int + """The ID of the Discord bot/server that received a vote.""" + + voter_id: int + """The ID of the Top.gg user who voted.""" + + is_server: bool + """Whether this vote's receiver is a Discord server.""" + + is_test: bool + """Whether this vote is just a test done from the page settings.""" + + is_weekend: bool + """ + Whether the weekend multiplier is active, where a single vote counts as two. + + If the dispatched event came from a server being voted, this will always be ``false``. + """ + + query: dict[str, str] + """Query strings found on the vote page.""" + + def __init__(self, json: dict): + self.receiver_id = int(json.get('bot', json['guild'])) + self.voter_id = int(json['user']) + self.is_server = bool(json.get('guild')) + self.is_test = json['type'] == 'test' + self.is_weekend = bool(json.get('isWeekend')) + + if query := json.get('query'): + self.query = { + k: v[0] for k, v in parse.parse_qs(parse.urlsplit(query).query).items() + } + else: + self.query = {} + + +OnVoteCallback = Callable[[Vote], Any] +OnVoteDecorator = Callable[[OnVoteCallback], RawCallback] + + +class Webhooks: + """ + Receive events from the Top.gg servers. + + :param auth: The default password to use. Defaults to :py:obj:`None`. + :type auth: Optional[:py:class:`str`] + :param port: The default port to use. Defaults to :py:obj:`None`. + :type port: Optional[:py:class:`int`] + """ + + __slots__ = ('__app', '__server', '__default_auth', '__default_port', '__running') + + def __init__(self, auth: Optional[str] = None, port: Optional[int] = None) -> None: + self.__app = web.Application() + self.__server = None + self.__default_auth = auth + self.__default_port = port + self.__running = False + + def on_vote( + self, + route: str, + *, + auth: Optional[str] = None, + callback: Optional[OnVoteCallback] = None, + ) -> Union[OnVoteCallback, OnVoteDecorator]: + """ + Registers a handler to receive whenever your Discord bot/server receives a vote. + + :param route: The route to use. + :type route: :py:class:`str` + :param auth: The password to override and use. Defaults to the default password provided in the constructor call. + :type auth: Optional[:py:class:`str`] + :param callback: The callback to override and use. If this is :py:obj:`None`, this method relies on the decorator input. + :type callback: Optional[:data:`~.webhooks.OnVoteCallback`] + + :exception TypeError: If the route argument is not a :py:class:`str`. + + :returns: The function itself or a decorated function depending on the argument. + :rtype: Union[:data:`~.webhooks.OnVoteCallback`, :data:`~.webhooks.OnVoteDecorator`] + """ + + if not isinstance(route, str): + raise TypeError('Missing route argument.') + + if auth is None: + auth = self.__default_auth + + def decorator(inner_callback: OnVoteCallback) -> RawCallback: + async def handler(request: web.Request) -> web.Response: + if request.headers.get('Authorization', '') != auth: + return web.Response(status=401, text='Unauthorized') + + response = inner_callback(Vote(await request.json())) + + if isawaitable(response): + await response + + return web.Response(status=200, text='OK') + + self.__app.router.add_post(route, handler) + + return handler + + if callback is not None: + decorator(callback) + + return callback + + return decorator + + async def start(self, port: Optional[int] = None) -> None: + """ + Starts the webhook server. Has no effect if the server is already running. + + :param port: The port to override and use. Defaults to the default port provided in the constructor call. + :type port: Optional[:py:class:`int`] + + :exception TypeError: If the port is not provided either here or in the constructor call. + """ + + if not self.__running: + if port is None: + port = self.__default_port + + if port is None: + raise TypeError('Missing port.') + + runner = web.AppRunner(self.__app) + await runner.setup() + + self.__server = web.TCPSite(runner, '0.0.0.0', port) + await self.__server.start() + + self.__running = True + + async def close(self) -> None: + """ + Closes the webhook server. Has no effect if the server is already closed. + """ + + if self.__running: + await self.__server.stop() + + self.__running = False + + @property + def is_running(self) -> bool: + """Whether the webhook server is running.""" + + return self.__running + + @property + def app(self) -> web.Application: + """The ``aiohttp`` :class:`~aiohttp.web.Application` that this webhook server uses.""" + + return self.__app + + async def __aenter__(self) -> 'Webhooks': + await self.start() + + return self + + async def __aexit__(self, *_, **__) -> None: + await self.close() From 6a9d21e1dbf12af661486ce57d92dac0e37b8e9a Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 5 Mar 2025 16:53:17 +0700 Subject: [PATCH 094/126] meta: ruff ignore E402 --- ruff.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ruff.toml b/ruff.toml index ac0f30da..c1b92a19 100644 --- a/ruff.toml +++ b/ruff.toml @@ -7,4 +7,4 @@ line-ending = "lf" quote-style = "single" [lint] -ignore = ["E722", "F401", "F403"] \ No newline at end of file +ignore = ["E402", "E722", "F401", "F403"] \ No newline at end of file From 40a1dd8019027df3b6b2d49834616115cfd55ee4 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 5 Mar 2025 16:56:12 +0700 Subject: [PATCH 095/126] fix: raise on missing password --- topgg/webhooks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/topgg/webhooks.py b/topgg/webhooks.py index e2a4e020..afcb523d 100644 --- a/topgg/webhooks.py +++ b/topgg/webhooks.py @@ -115,7 +115,7 @@ def on_vote( :param callback: The callback to override and use. If this is :py:obj:`None`, this method relies on the decorator input. :type callback: Optional[:data:`~.webhooks.OnVoteCallback`] - :exception TypeError: If the route argument is not a :py:class:`str`. + :exception TypeError: If the route argument is not a :py:class:`str` or if the password is not provided. :returns: The function itself or a decorated function depending on the argument. :rtype: Union[:data:`~.webhooks.OnVoteCallback`, :data:`~.webhooks.OnVoteDecorator`] @@ -127,6 +127,9 @@ def on_vote( if auth is None: auth = self.__default_auth + if auth is None: + raise TypeError('Missing password.') + def decorator(inner_callback: OnVoteCallback) -> RawCallback: async def handler(request: web.Request) -> web.Response: if request.headers.get('Authorization', '') != auth: From c9132f06ad05d21646a7d8ea1b44c86524cc9807 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 5 Mar 2025 17:03:33 +0700 Subject: [PATCH 096/126] doc: make it clear that it only returns at most 100 --- topgg/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/topgg/client.py b/topgg/client.py index b5bd015f..b7b8968e 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -276,7 +276,7 @@ async def is_weekend(self) -> bool: async def get_voters(self, page: int = 1) -> Iterable[Voter]: """ - Fetches and yields your Discord bot's recent unique voters. + Fetches and yields your Discord bot's recent 100 unique voters. :param page: The page number. Each page can only have at most 100 voters. Defaults to 1. :type page: :py:class:`int` From 4e452fb180ee7a374a759862c614d9085fa1d755 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 5 Mar 2025 17:42:56 +0700 Subject: [PATCH 097/126] feat: make id readonly --- topgg/client.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index b7b8968e..2c3737d6 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -79,7 +79,7 @@ class Client: '__autopost_retrieval_callback', '__autopost_success_callbacks', '__autopost_error_callbacks', - 'id', + '__id', ) def __init__(self, token: str, *, session: Optional[ClientSession] = None): @@ -96,7 +96,7 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): encoded_json = token.split('.')[1] encoded_json += '=' * (4 - (len(encoded_json) % 4)) - self.id = int(loads(b64decode(encoded_json))['id']) + self.__id = int(loads(b64decode(encoded_json))['id']) except: raise ValueError('Got a malformed API token.') @@ -116,6 +116,15 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): def __repr__(self) -> str: return f'<{__class__.__name__} {self.__session!r}>' + @property + def id(self) -> int: + """The Discord ID associated with this API token.""" + + return self.__id + + def __int__(self) -> int: + return self.id + async def __request( self, method: str, @@ -232,7 +241,7 @@ async def get_server_count(self) -> Optional[int]: :rtype: Optional[:py:class:`int`] """ - stats = await self.__request('GET', f'/bots/{self.id}/stats') + stats = await self.__request('GET', f'/bots/{self.__id}/stats') return stats and stats.get('server_count') @@ -255,7 +264,7 @@ async def post_server_count(self, new_server_count: int) -> None: ) await self.__request( - 'POST', f'/bots/{self.id}/stats', json={'server_count': new_server_count} + 'POST', f'/bots/{self.__id}/stats', json={'server_count': new_server_count} ) async def is_weekend(self) -> bool: @@ -290,7 +299,7 @@ async def get_voters(self, page: int = 1) -> Iterable[Voter]: """ voters = 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)} ) return map(Voter, voters or ()) @@ -311,7 +320,7 @@ async def has_voted(self, id: int) -> bool: """ response = await self.__request( - 'GET', f'/bots/{self.id}/check?userId={id}', treat_404_as_none=False + 'GET', f'/bots/{self.__id}/check?userId={id}', treat_404_as_none=False ) return bool(response['voted']) @@ -417,7 +426,7 @@ def decorator(callback: AutopostErrorCallback) -> AutopostErrorCallback: return callback if callback is not None: - decorator + decorator(callback) return self From dd7d65e47ef3a619469fd27a43021aed8681f67b Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 5 Mar 2025 17:48:24 +0700 Subject: [PATCH 098/126] feat: add __repr__ implementations --- topgg/client.py | 2 +- topgg/webhooks.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/topgg/client.py b/topgg/client.py index 2c3737d6..8c24cfa0 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -121,7 +121,7 @@ def id(self) -> int: """The Discord ID associated with this API token.""" return self.__id - + def __int__(self) -> int: return self.id diff --git a/topgg/webhooks.py b/topgg/webhooks.py index afcb523d..24e2a591 100644 --- a/topgg/webhooks.py +++ b/topgg/webhooks.py @@ -74,6 +74,11 @@ def __init__(self, json: dict): else: self.query = {} + def __repr__(self) -> str: + return ( + f'<{__class__.__name__} receiver_id={self.receiver_id} voter_id={self.voter_id}>' + ) + OnVoteCallback = Callable[[Vote], Any] OnVoteDecorator = Callable[[OnVoteCallback], RawCallback] @@ -98,6 +103,9 @@ def __init__(self, auth: Optional[str] = None, port: Optional[int] = None) -> No self.__default_port = port self.__running = False + def __repr__(self) -> str: + return f'<{__class__.__name__} app={self.__app!r} running={self.__running}>' + def on_vote( self, route: str, From 265ab7b28192de91a91b6658dec4ec846e302878 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 5 Mar 2025 18:08:37 +0700 Subject: [PATCH 099/126] doc: reword documentation --- README.md | 14 ++++++++----- docs/index.rst | 16 +++++++++------ topgg/client.py | 52 +++++++++++++++++++++++------------------------ topgg/errors.py | 2 +- topgg/models.py | 14 ++++++------- topgg/webhooks.py | 10 +++------ 6 files changed, 56 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 9607e961..fb5d302d 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,19 @@ [pypi-url]: https://pypi.org/project/topggpy/ [downloads-image]: https://img.shields.io/pypi/dm/topggpy?style=flat-square -The community-maintained Python API wrapper for [Top.gg](https://top.gg/) +A simple API wrapper for [Top.gg](https://top.gg) written in Python. -## Installation +## Getting started + +Make sure you already have an API token handy. See [this tutorial](https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff) on how to retrieve it. + +After that, run the following command in your terminal: ```console pip install topggpy ``` -## Basic example +## Basic examples For more information, please read the [documentation](https://topggpy.readthedocs.io/en/latest/). @@ -65,7 +69,7 @@ async def main() -> None: if has_voted: print('This user has voted!') - # Check if the weekend multiplier is active. + # Check if the weekend multiplier is active, where a single vote counts as two. is_weekend = await tg.is_weekend() if is_weekend: @@ -82,7 +86,7 @@ if __name__ == '__main__': asyncio.run(main()) ``` -# Autoposting example +## Autoposting example ```py # Import the module. diff --git a/docs/index.rst b/docs/index.rst index 7ed66fcc..b2f361ad 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,17 +10,21 @@ topggpy_ .. |downloads| image:: https://img.shields.io/pypi/dm/topggpy?style=flat-square .. _downloads: https://pypi.org/project/topggpy/ -The community-maintained Python API wrapper for `Top.gg `_. +A simple API wrapper for `Top.gg `_ written in Python. -Installation ------------- +Getting started +--------------- + +Make sure you already have an API token handy. See `this tutorial `_ on how to retrieve it. + +After that, run the following command in your terminal: .. code-block:: console $ pip install topggpy -Basic example -------------- +Basic examples +-------------- .. code-block:: python @@ -72,7 +76,7 @@ Basic example if has_voted: print('This user has voted!') - # Check if the weekend multiplier is active. + # Check if the weekend multiplier is active, where a single vote counts as two. is_weekend = await tg.is_weekend() if is_weekend: diff --git a/topgg/client.py b/topgg/client.py index 8c24cfa0..c68752d5 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -59,13 +59,13 @@ class Client: """ Interact with the API's endpoints. - :param token: The API token to use with the API. To retrieve your API token, see https://docs.top.gg/docs/API/@reference. + :param token: The API token to use. To retrieve it, see https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff. :type token: :py:class:`str` :param session: Whether to use an existing :class:`~aiohttp.ClientSession` for requesting or not. Defaults to :py:obj:`None` (creates a new one instead) :type session: Optional[:class:`~aiohttp.ClientSession`] - :exception TypeError: If ``token`` is not a :py:class:`str`. - :exception ValueError: If ``token`` is not a valid API token. + :exception TypeError: ``token`` is not a :py:class:`str`. + :exception ValueError: ``token`` is not a valid API token. """ __slots__: tuple[str, ...] = ( @@ -207,9 +207,9 @@ async def get_bot(self, id: int) -> Optional[Bot]: :param id: The requested ID. :type id: :py:class:`int` - :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. - :exception RequestError: If the client received a non-favorable response from the API. - :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. + :exception Error: The client is already closed. + :exception RequestError: Received a non-favorable response from the API. + :exception Ratelimited: Ratelimited from sending more requests. :returns: The requested bot. This can be :py:obj:`None` if it does not exist. :rtype: Optional[:class:`~.models.Bot`] @@ -221,9 +221,9 @@ async def get_bot(self, id: int) -> Optional[Bot]: def get_bots(self) -> BotQuery: """ - Fetches and yields Discord bots that matches the specified query. + Returns a :class:`~.models.BotQuery` object that allows you to configure a bot query before sending it to the API. - :returns: A :class:`~.models.BotQuery` object, which allows you to configure a query before sending it to the API. + :returns: A :class:`~.models.BotQuery` object that allows you to configure a bot query before sending it to the API. :rtype: :class:`~.models.BotQuery` """ @@ -233,9 +233,9 @@ async def get_server_count(self) -> Optional[int]: """ Fetches your Discord bot's posted server count. - :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. - :exception RequestError: If the client received a non-favorable response from the API. - :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. + :exception Error: The client is already closed. + :exception RequestError: Received a non-favorable response from the API. + :exception Ratelimited: Ratelimited from sending more requests. :returns: The posted server count. This can be :py:obj:`None` if it does not exist. :rtype: Optional[:py:class:`int`] @@ -252,10 +252,10 @@ async def post_server_count(self, new_server_count: int) -> None: :param new_server_count: The new server count to post. This cannot be zero. :type new_server_count: :py:class:`int` - :exception ValueError: If the new_server_count argument is zero or lower. - :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. - :exception RequestError: If the client received a non-favorable response from the API. - :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. + :exception ValueError: The new_server_count argument is zero or lower. + :exception Error: The client is already closed. + :exception RequestError: Received a non-favorable response from the API. + :exception Ratelimited: Ratelimited from sending more requests. """ if new_server_count <= 0: @@ -269,11 +269,11 @@ async def post_server_count(self, new_server_count: int) -> None: async def is_weekend(self) -> bool: """ - Checks if the weekend multiplier is active. + Checks if the weekend multiplier is active, where a single vote counts as two. - :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. - :exception RequestError: If the client received a non-favorable response from the API. - :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. + :exception Error: The client is already closed. + :exception RequestError: Received a non-favorable response from the API. + :exception Ratelimited: Ratelimited from sending more requests. :returns: Whether the weekend multiplier is active. :rtype: bool @@ -290,9 +290,9 @@ async def get_voters(self, page: int = 1) -> Iterable[Voter]: :param page: The page number. Each page can only have at most 100 voters. Defaults to 1. :type page: :py:class:`int` - :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. - :exception RequestError: If the client received a non-favorable response from the API. - :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. + :exception Error: The client is already closed. + :exception RequestError: Received a non-favorable response from the API. + :exception Ratelimited: Ratelimited from sending more requests. :returns: A generator of your bot's recent unique voters. :rtype: Iterable[:class:`~.models.Voter`] @@ -311,9 +311,9 @@ async def has_voted(self, id: int) -> bool: :param id: The requested user's ID. :type id: :py:class:`int` - :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. - :exception RequestError: If the client received a non-favorable response from the API. - :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. + :exception Error: The client is already closed. + :exception RequestError: Received a non-favorable response from the API. + :exception Ratelimited: Ratelimited from sending more requests. :returns: Whether the specified user has voted your bot. :rtype: bool @@ -439,7 +439,7 @@ def start_autoposter(self, interval: Optional[float] = None) -> None: :param interval: The interval between posting in seconds. Defaults to 15 minutes. :type interval: Optional[:py:class:`float`] - :exception TypeError: If the autoposter server count retrieval callback does not exist. + :exception TypeError: The server count retrieval callback does not exist. """ if self.__autopost_task is None: diff --git a/topgg/errors.py b/topgg/errors.py index 922c0c0e..f39801af 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -59,7 +59,7 @@ class Ratelimited(Error): __slots__: tuple[str, ...] = ('retry_after',) retry_after: float - """How long the client should wait (in seconds) until it can make a request to the API again.""" + """How long the client should wait (in seconds) before it can make a request to the API again.""" def __init__(self, retry_after: float): self.retry_after = retry_after diff --git a/topgg/models.py b/topgg/models.py index 8d1b8f2d..7bb16f77 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -133,7 +133,7 @@ class Bot: """This bot's GitHub repository URL.""" owners: list[int] - """This bot's owners IDs.""" + """This bot's owner IDs.""" banner_url: Optional[str] """This bot's banner URL.""" @@ -211,7 +211,7 @@ def created_at(self) -> datetime: class BotQuery: - """Configure a query before sending it to the API.""" + """Configure a Discord bot query before sending it to the API.""" __slots__: tuple[str, ...] = ('__client', '__params', '__search', '__sort') @@ -277,9 +277,9 @@ def limit(self, limit: int) -> 'BotQuery': def skip(self, skip: int) -> 'BotQuery': """ - Sets the amount of bots to be skipped during the query. + Sets the amount of bots to be skipped. - :param id: The amount of bots to be skipped during the query. This cannot be more than 499. + :param id: The amount of bots to be skipped. This cannot be more than 499. :type id: :py:class:`int` :returns: The same object. This allows this object to have chained method calls. @@ -369,9 +369,9 @@ async def send(self) -> Iterable[Bot]: """ Sends the query to the API. - :exception Error: If the :class:`~aiohttp.ClientSession` used by the client is already closed. - :exception RequestError: If the client received a non-favorable response from the API. - :exception Ratelimited: If the client got blocked by the API for an hour because it exceeded its ratelimits. + :exception Error: The client is already closed. + :exception RequestError: Received a non-favorable response from the API. + :exception Ratelimited: Ratelimited from sending more requests. :returns: A generator of matching bots. :rtype: Iterable[:class:`~.models.Bot`] diff --git a/topgg/webhooks.py b/topgg/webhooks.py index 24e2a591..86720597 100644 --- a/topgg/webhooks.py +++ b/topgg/webhooks.py @@ -51,11 +51,7 @@ class Vote: """Whether this vote is just a test done from the page settings.""" is_weekend: bool - """ - Whether the weekend multiplier is active, where a single vote counts as two. - - If the dispatched event came from a server being voted, this will always be ``false``. - """ + """Whether the weekend multiplier is active, where a single vote counts as two.""" query: dict[str, str] """Query strings found on the vote page.""" @@ -123,7 +119,7 @@ def on_vote( :param callback: The callback to override and use. If this is :py:obj:`None`, this method relies on the decorator input. :type callback: Optional[:data:`~.webhooks.OnVoteCallback`] - :exception TypeError: If the route argument is not a :py:class:`str` or if the password is not provided. + :exception TypeError: the route argument is not a :py:class:`str` or if the password is not provided. :returns: The function itself or a decorated function depending on the argument. :rtype: Union[:data:`~.webhooks.OnVoteCallback`, :data:`~.webhooks.OnVoteDecorator`] @@ -168,7 +164,7 @@ async def start(self, port: Optional[int] = None) -> None: :param port: The port to override and use. Defaults to the default port provided in the constructor call. :type port: Optional[:py:class:`int`] - :exception TypeError: If the port is not provided either here or in the constructor call. + :exception TypeError: the port is not provided either here or in the constructor call. """ if not self.__running: From a4470878a12dac18f95f73a41cb38888738e0f8d Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 6 Mar 2025 14:50:14 +0700 Subject: [PATCH 100/126] feat: remove treat 404 as None --- topgg/client.py | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index c68752d5..b8948e51 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -131,8 +131,7 @@ async def __request( path: str, params: Optional[dict] = None, json: Optional[dict] = None, - treat_404_as_none: bool = True, - ) -> Optional[dict]: + ) -> dict: if self.__session.closed: raise Error('Client session is already closed.') @@ -186,9 +185,7 @@ async def __request( return json except: - if status == 404 and treat_404_as_none: - return - elif status == 429: + if status == 429: if retry_after > MAXIMUM_DELAY_THRESHOLD: self.__current_ratelimit = time() + retry_after @@ -200,7 +197,7 @@ async def __request( raise RequestError(json and json.get('message'), status) from None - async def get_bot(self, id: int) -> Optional[Bot]: + async def get_bot(self, id: int) -> Bot: """ Fetches a Discord bot from its ID. @@ -208,16 +205,14 @@ async def get_bot(self, id: int) -> Optional[Bot]: :type id: :py:class:`int` :exception Error: The client is already closed. - :exception RequestError: Received a non-favorable response from the API. + :exception RequestError: The specified bot does not exist or the client has received other non-favorable responses from the API. :exception Ratelimited: Ratelimited from sending more requests. - :returns: The requested bot. This can be :py:obj:`None` if it does not exist. - :rtype: Optional[:class:`~.models.Bot`] + :returns: The requested bot. + :rtype: :class:`~.models.Bot` """ - bot = await self.__request('GET', f'/bots/{id}') - - return bot and Bot(bot) + return Bot(await self.__request('GET', f'/bots/{id}')) def get_bots(self) -> BotQuery: """ @@ -243,7 +238,7 @@ async def get_server_count(self) -> Optional[int]: stats = await self.__request('GET', f'/bots/{self.__id}/stats') - return stats and stats.get('server_count') + return stats.get('server_count') async def post_server_count(self, new_server_count: int) -> None: """ @@ -279,7 +274,7 @@ async def is_weekend(self) -> bool: :rtype: bool """ - response = await self.__request('GET', '/weekend', treat_404_as_none=False) + response = await self.__request('GET', '/weekend') return response['is_weekend'] @@ -298,12 +293,13 @@ async def get_voters(self, page: int = 1) -> Iterable[Voter]: :rtype: Iterable[:class:`~.models.Voter`] """ - voters = await self.__request( - 'GET', f'/bots/{self.__id}/votes', params={'page': max(page, 1)} + return map( + Voter, + await self.__request( + 'GET', f'/bots/{self.__id}/votes', params={'page': max(page, 1)} + ), ) - return map(Voter, voters or ()) - async def has_voted(self, id: int) -> bool: """ Checks if the specified Discord user has voted your Discord bot. @@ -312,16 +308,14 @@ async def has_voted(self, id: int) -> bool: :type id: :py:class:`int` :exception Error: The client is already closed. - :exception RequestError: Received a non-favorable response from the API. + :exception RequestError: The specified user has not logged in to Top.gg or the client has received other non-favorable responses from the API. :exception Ratelimited: Ratelimited from sending more requests. :returns: Whether the specified user has voted your bot. :rtype: bool """ - response = await self.__request( - 'GET', f'/bots/{self.__id}/check?userId={id}', treat_404_as_none=False - ) + response = await self.__request('GET', f'/bots/{self.__id}/check?userId={id}') return bool(response['voted']) From 4137b4edf87f8356d04a2a17a32f05fbed4b1f6b Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 6 Mar 2025 14:56:09 +0700 Subject: [PATCH 101/126] refactor: remove or {} check --- topgg/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/topgg/models.py b/topgg/models.py index 7bb16f77..60ad4c49 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -383,6 +383,6 @@ async def send(self) -> Iterable[Bot]: if self.__sort: params['sort'] = self.__sort - bots = (await self.__client._Client__request('GET', '/bots', params=params)) or {} + bots = await self.__client._Client__request('GET', '/bots', params=params) return map(Bot, bots.get('results', ())) From d9d61b3bf8bd442243e69afa75b0870f8a6e8e81 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 6 Mar 2025 15:03:43 +0700 Subject: [PATCH 102/126] doc: add or is empty --- topgg/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/topgg/client.py b/topgg/client.py index b8948e51..cceb335e 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -64,7 +64,7 @@ class Client: :param session: Whether to use an existing :class:`~aiohttp.ClientSession` for requesting or not. 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`. + :exception TypeError: ``token`` is not a :py:class:`str` or is empty. :exception ValueError: ``token`` is not a valid API token. """ From 81bb72b2732063cd4501a635fb53b7991d3ea53d Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 6 Mar 2025 15:17:19 +0700 Subject: [PATCH 103/126] doc: shorten documentation --- README.md | 4 ++-- docs/index.rst | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fb5d302d..0ce4de82 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ import os async def main() -> None: - # Declare the client. To retrieve your top.gg token, see https://docs.top.gg/docs/API/@reference. + # Declare the client. async with topgg.Client(os.getenv('TOPGG_TOKEN')) as tg: # Fetch a bot from its ID. @@ -98,7 +98,7 @@ import os async def main() -> None: - # Declare the client. To retrieve your top.gg token, see https://docs.top.gg/docs/API/@reference. + # Declare the client. tg = topgg.Client(os.getenv('TOPGG_TOKEN')) # Callback to retrieve server count data (required). diff --git a/docs/index.rst b/docs/index.rst index b2f361ad..9ab7f588 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -37,7 +37,7 @@ Basic examples async def main() -> None: - # Declare the client. To retrieve your top.gg token, see https://docs.top.gg/docs/API/@reference. + # Declare the client. async with topgg.Client(os.getenv('TOPGG_TOKEN')) as tg: # Fetch a bot from its ID. @@ -106,7 +106,7 @@ Autoposting example async def main() -> None: - # Declare the client. To retrieve your top.gg token, see https://docs.top.gg/docs/API/@reference. + # Declare the client. tg = topgg.Client(os.getenv('TOPGG_TOKEN')) # Callback to retrieve server count data (required). From 02bbc4fe6e0b535b5ac57985af1628414f1eb4a4 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 6 Mar 2025 15:20:04 +0700 Subject: [PATCH 104/126] doc: add $ --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0ce4de82..e862dac8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Make sure you already have an API token handy. See [this tutorial](https://githu After that, run the following command in your terminal: ```console -pip install topggpy +$ pip install topggpy ``` ## Basic examples From 52afa09d71caba98bbb81d8365b3a453b6772835 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 6 Mar 2025 20:20:20 +0700 Subject: [PATCH 105/126] doc: reword documentation --- topgg/client.py | 6 +++--- topgg/models.py | 22 +++++++++++----------- topgg/webhooks.py | 4 ++-- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index cceb335e..4a336122 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -209,7 +209,7 @@ async def get_bot(self, id: int) -> Bot: :exception Ratelimited: Ratelimited from sending more requests. :returns: The requested bot. - :rtype: :class:`~.models.Bot` + :rtype: Bot """ return Bot(await self.__request('GET', f'/bots/{id}')) @@ -219,7 +219,7 @@ def get_bots(self) -> BotQuery: Returns a :class:`~.models.BotQuery` object that allows you to configure a bot query before sending it to the API. :returns: A :class:`~.models.BotQuery` object that allows you to configure a bot query before sending it to the API. - :rtype: :class:`~.models.BotQuery` + :rtype: BotQuery """ return BotQuery(self) @@ -290,7 +290,7 @@ async def get_voters(self, page: int = 1) -> Iterable[Voter]: :exception Ratelimited: Ratelimited from sending more requests. :returns: A generator of your bot's recent unique voters. - :rtype: Iterable[:class:`~.models.Voter`] + :rtype: Iterable[Voter] """ return map( diff --git a/topgg/models.py b/topgg/models.py index 60ad4c49..820f7c10 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -229,7 +229,7 @@ def sort_by_id(self) -> 'BotQuery': Sorts results based on each bot's ID. :returns: The same object. This allows this object to have chained method calls. - :rtype: :class:`~.models.BotQuery` + :rtype: BotQuery """ self.__sort = 'id' @@ -241,7 +241,7 @@ def sort_by_submission_date(self) -> 'BotQuery': Sorts results based on each bot's submission date. :returns: The same object. This allows this object to have chained method calls. - :rtype: :class:`~.models.BotQuery` + :rtype: BotQuery """ self.__sort = 'date' @@ -253,7 +253,7 @@ def sort_by_monthly_votes(self) -> 'BotQuery': Sorts results based on each bot's monthly vote count. :returns: The same object. This allows this object to have chained method calls. - :rtype: :class:`~.models.BotQuery` + :rtype: BotQuery """ self.__sort = 'monthlyPoints' @@ -268,7 +268,7 @@ def limit(self, limit: int) -> 'BotQuery': :type id: :py:class:`int` :returns: The same object. This allows this object to have chained method calls. - :rtype: :class:`~.models.BotQuery` + :rtype: BotQuery """ self.__params['limit'] = max(min(limit, 500), 1) @@ -283,7 +283,7 @@ def skip(self, skip: int) -> 'BotQuery': :type id: :py:class:`int` :returns: The same object. This allows this object to have chained method calls. - :rtype: :class:`~.models.BotQuery` + :rtype: BotQuery """ self.__params['offset'] = max(min(skip, 499), 0) @@ -298,7 +298,7 @@ def name(self, name: str) -> 'BotQuery': :type id: :py:class:`str` :returns: The same object. This allows this object to have chained method calls. - :rtype: :class:`~.models.BotQuery` + :rtype: BotQuery """ self.__search['username'] = name @@ -313,7 +313,7 @@ def prefix(self, prefix: str) -> 'BotQuery': :type id: :py:class:`str` :returns: The same object. This allows this object to have chained method calls. - :rtype: :class:`~.models.BotQuery` + :rtype: BotQuery """ self.__search['prefix'] = prefix @@ -328,7 +328,7 @@ def votes(self, votes: int) -> 'BotQuery': :type id: :py:class:`int` :returns: The same object. This allows this object to have chained method calls. - :rtype: :class:`~.models.BotQuery` + :rtype: BotQuery """ self.__search['points'] = max(votes, 0) @@ -343,7 +343,7 @@ def monthly_votes(self, monthly_votes: int) -> 'BotQuery': :type id: :py:class:`int` :returns: The same object. This allows this object to have chained method calls. - :rtype: :class:`~.models.BotQuery` + :rtype: BotQuery """ self.__search['monthlyPoints'] = max(monthly_votes, 0) @@ -358,7 +358,7 @@ def vanity(self, vanity: str) -> 'BotQuery': :type id: :py:class:`str` :returns: The same object. This allows this object to have chained method calls. - :rtype: :class:`~.models.BotQuery` + :rtype: BotQuery """ self.__search['vanity'] = vanity @@ -374,7 +374,7 @@ async def send(self) -> Iterable[Bot]: :exception Ratelimited: Ratelimited from sending more requests. :returns: A generator of matching bots. - :rtype: Iterable[:class:`~.models.Bot`] + :rtype: Iterable[Bot] """ params = self.__params.copy() diff --git a/topgg/webhooks.py b/topgg/webhooks.py index 86720597..34fabbb4 100644 --- a/topgg/webhooks.py +++ b/topgg/webhooks.py @@ -84,9 +84,9 @@ class Webhooks: """ Receive events from the Top.gg servers. - :param auth: The default password to use. Defaults to :py:obj:`None`. + :param auth: The default password to use. :type auth: Optional[:py:class:`str`] - :param port: The default port to use. Defaults to :py:obj:`None`. + :param port: The default port to use. :type port: Optional[:py:class:`int`] """ From 9e4aaa07bf38482638772610e7414391879b2282 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 10 Mar 2025 11:46:38 +0700 Subject: [PATCH 106/126] feat: replace BotQuery to make this less breaking --- README.md | 12 ++- docs/client.rst | 3 + docs/data.rst | 3 - docs/index.rst | 12 ++- pyproject.toml | 2 +- test.py | 18 ++--- topgg/__init__.py | 4 +- topgg/client.py | 105 ++++++++++++++++++------ topgg/errors.py | 4 +- topgg/models.py | 199 +++++----------------------------------------- topgg/webhooks.py | 9 +-- 11 files changed, 127 insertions(+), 244 deletions(-) diff --git a/README.md b/README.md index e862dac8..794efbb0 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,11 @@ async def main() -> None: print(bot) # Fetch bots that matches the specified query. - bots = ( - await tg.get_bots() - .limit(250) - .skip(50) - .name('shiro') - .sort_by_monthly_votes() - .send() + bots = await tg.get_bots( + limit=250, + offset=50, + username='Shiro', + sort_by=topgg.SortBy.MONTHLY_VOTES ) for b in bots: diff --git a/docs/client.rst b/docs/client.rst index b38f93c3..022df0fa 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -4,6 +4,9 @@ Client reference .. autoclass:: topgg.client.Client :members: +.. autoclass:: topgg.models.SortBy() + :members: + .. autoclass:: topgg.errors.Error() .. autoclass:: topgg.errors.RequestError() diff --git a/docs/data.rst b/docs/data.rst index b948b245..848f3611 100644 --- a/docs/data.rst +++ b/docs/data.rst @@ -4,8 +4,5 @@ Data reference .. autoclass:: topgg.models.Bot() :members: -.. autoclass:: topgg.models.BotQuery() - :members: - .. autoclass:: topgg.models.Voter() :members: \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 9ab7f588..d34a16b3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -46,13 +46,11 @@ Basic examples print(bot) # Fetch bots that matches the specified query. - bots = ( - await tg.get_bots() - .limit(250) - .skip(50) - .name('shiro') - .sort_by_monthly_votes() - .send() + bots = await tg.get_bots( + limit=250, + offset=50, + username='Shiro', + sort_by=topgg.SortBy.MONTHLY_VOTES ) for b in bots: diff --git a/pyproject.toml b/pyproject.toml index bdbf700b..9fdbe824 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.11.12"] +dependencies = ["aiohttp>=3.11.13"] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", diff --git a/test.py b/test.py index e6b1997f..1a80e11c 100644 --- a/test.py +++ b/test.py @@ -41,18 +41,16 @@ def test_attributes(obj: object) -> None: async def run() -> None: async with topgg.Client(os.getenv('TOPGG_TOKEN')) as tg: - bot = await tg.get_bot(432610292342587392) + #bot = await tg.get_bot(432610292342587392) - test_attributes(bot) + #test_attributes(bot) - await asyncio.sleep(1) - bots = ( - await tg.get_bots() - .limit(250) - .skip(50) - .name('shiro') - .sort_by_monthly_votes() - .send() + #await asyncio.sleep(1) + bots = await tg.get_bots( + limit=250, + offset=50, + username='Shiro', + sort_by=topgg.SortBy.MONTHLY_VOTES ) for b in bots: diff --git a/topgg/__init__.py b/topgg/__init__.py index 9a9b8cf9..3e696dbb 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -24,7 +24,7 @@ """ from .errors import Error, RequestError, Ratelimited -from .models import Bot, BotQuery, Voter +from .models import Bot, SortBy, Voter from .webhooks import Vote, Webhooks from .version import VERSION from .client import Client @@ -40,11 +40,11 @@ __version__ = VERSION __all__ = ( 'Bot', - 'BotQuery', 'Client', 'Error', 'Ratelimited', 'RequestError', + 'SortBy', 'VERSION', 'Vote', 'Voter', diff --git a/topgg/client.py b/topgg/client.py index 4a336122..fd93ef08 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -35,7 +35,7 @@ from .ratelimiter import Ratelimiter, RatelimiterManager from .errors import Error, RequestError, Ratelimited -from .models import Bot, BotQuery, Voter +from .models import Bot, SortBy, Voter from .version import VERSION @@ -79,7 +79,6 @@ class Client: '__autopost_retrieval_callback', '__autopost_success_callbacks', '__autopost_error_callbacks', - '__id', ) def __init__(self, token: str, *, session: Optional[ClientSession] = None): @@ -96,7 +95,7 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): encoded_json = token.split('.')[1] encoded_json += '=' * (4 - (len(encoded_json) % 4)) - self.__id = int(loads(b64decode(encoded_json))['id']) + int(loads(b64decode(encoded_json))['id']) except: raise ValueError('Got a malformed API token.') @@ -116,12 +115,6 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): def __repr__(self) -> str: return f'<{__class__.__name__} {self.__session!r}>' - @property - def id(self) -> int: - """The Discord ID associated with this API token.""" - - return self.__id - def __int__(self) -> int: return self.id @@ -205,24 +198,78 @@ async def get_bot(self, id: int) -> Bot: :type id: :py:class:`int` :exception Error: The client is already closed. - :exception RequestError: The specified bot does not exist or the client has received other non-favorable responses from the API. + :exception RequestError: Such query does not exist or the client has received other non-favorable responses from the API. :exception Ratelimited: Ratelimited from sending more requests. :returns: The requested bot. - :rtype: Bot + :rtype: :class:`.Bot` """ return Bot(await self.__request('GET', f'/bots/{id}')) - def get_bots(self) -> BotQuery: + async def get_bots( + self, + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + sort_by: Optional[SortBy] = None, + votes: Optional[int] = None, + monthly_votes: Optional[int] = None, + **search: Optional[str] + ) -> Iterable[Bot]: """ - Returns a :class:`~.models.BotQuery` object that allows you to configure a bot query before sending it to the API. + Fetches and yields Discord bots that matches the specified query. + + :param limit: The maximum amount of bots to be queried. + :type limit: Optional[:py:class:`int`] + :param offset: The amount of bots to be skipped. + :type offset: Optional[:py:class:`int`] + :param sort_by: Sorts results based on a specific criteria. Results will always be descending. + :type sort_by: Optional[:class:`.SortBy`] + :param username: Queries only bots that has this username. + :type username: Optional[:py:class:`str`] + :param prefix: Queries only bots that has this prefix. + :type prefix: Optional[:py:class:`str`] + :param votes: Queries only bots that has this vote count. + :type votes: Optional[:py:class:`int`] + :param monthly_votes: Queries only bots that has this monthly vote count. + :type monthly_votes: Optional[:py:class:`int`] + :param vanity: Queries only bots that has this Top.gg vanity code. + :type vanity: Optional[:py:class:`str`] - :returns: A :class:`~.models.BotQuery` object that allows you to configure a bot query before sending it to the API. - :rtype: BotQuery + :exception Error: The client is already closed. + :exception RequestError: Received a non-favorable response from the API. + :exception Ratelimited: Ratelimited from sending more requests. + + :returns: A generator of matching bots. + :rtype: Iterable[:class:`.Bot`] """ - return BotQuery(self) + 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_by is not None: + if not isinstance(sort_by, SortBy): + raise TypeError(f'Expected sort_by to be a SortBy enum, got {sort_by.__class__.__name__}.') + + params['sort'] = sort_by.value + + if votes is not None: + search['points'] = max(votes, 0) + + if monthly_votes is not None: + search['monthlyPoints'] = max(monthly_votes, 0) + + params['search'] = ' '.join(f'{k}: {v}' for k, v in search.items()) + + bots = await self.__request('GET', '/bots', params=params) + + return map(Bot, bots.get('results', ())) async def get_server_count(self) -> Optional[int]: """ @@ -236,7 +283,7 @@ async def get_server_count(self) -> Optional[int]: :rtype: Optional[:py:class:`int`] """ - stats = await self.__request('GET', f'/bots/{self.__id}/stats') + stats = await self.__request('GET', '/bots/stats') return stats.get('server_count') @@ -259,7 +306,7 @@ async def post_server_count(self, new_server_count: int) -> None: ) await self.__request( - 'POST', f'/bots/{self.__id}/stats', json={'server_count': new_server_count} + 'POST', '/bots/stats', json={'server_count': new_server_count} ) async def is_weekend(self) -> bool: @@ -271,7 +318,7 @@ async def is_weekend(self) -> bool: :exception Ratelimited: Ratelimited from sending more requests. :returns: Whether the weekend multiplier is active. - :rtype: bool + :rtype: :py:class:`bool` """ response = await self.__request('GET', '/weekend') @@ -290,13 +337,13 @@ async def get_voters(self, page: int = 1) -> Iterable[Voter]: :exception Ratelimited: Ratelimited from sending more requests. :returns: A generator of your bot's recent unique voters. - :rtype: Iterable[Voter] + :rtype: Iterable[:class:`.Voter`] """ return map( Voter, await self.__request( - 'GET', f'/bots/{self.__id}/votes', params={'page': max(page, 1)} + 'GET', '/bots/votes', params={'page': max(page, 1)} ), ) @@ -312,10 +359,10 @@ async def has_voted(self, id: int) -> bool: :exception Ratelimited: Ratelimited from sending more requests. :returns: Whether the specified user has voted your bot. - :rtype: bool + :rtype: :py:class:`bool` """ - response = await self.__request('GET', f'/bots/{self.__id}/check?userId={id}') + response = await self.__request('GET', '/bots/check', params={'userId': id}) return bool(response['voted']) @@ -407,7 +454,7 @@ def autopost_error( """ Adds an autopost on error handler. Several callbacks are possible. - :param callback: The autopost on error handler. This can be asynchronous or synchronous, as long as it accepts an :class:`~.Error` argument for the request exception. + :param callback: The autopost on error handler. This can be asynchronous or synchronous, as long as it accepts an :class:`.Error` argument for the request exception. :type callback: Optional[:data:`~.client.AutopostErrorCallback`] :returns: The function itself or a decorated function depending on the argument. @@ -426,6 +473,12 @@ def decorator(callback: AutopostErrorCallback) -> AutopostErrorCallback: return decorator + @property + def autoposter_running(self) -> bool: + """Whether the autoposter is running.""" + + return self.__autopost_task is not None + def start_autoposter(self, interval: Optional[float] = None) -> None: """ Starts the autoposter. Has no effect if the autoposter is already running. @@ -436,7 +489,7 @@ def start_autoposter(self, interval: Optional[float] = None) -> None: :exception TypeError: The server count retrieval callback does not exist. """ - if self.__autopost_task is None: + if not self.autoposter_running: if self.__autopost_retrieval_callback is None: raise TypeError('Missing autopost_retrieval callback.') @@ -447,7 +500,7 @@ def stop_autoposter(self) -> None: Stops the autoposter. Has no effect if the autoposter is already stopped. """ - if self.__autopost_task is not None: + if self.autoposter_running: self.__autopost_task.cancel() self.__autopost_task = None diff --git a/topgg/errors.py b/topgg/errors.py index f39801af..2f534c24 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -33,7 +33,7 @@ class Error(Exception): class RequestError(Error): - """HTTP request failure. Extends :class:`~.errors.Error`.""" + """HTTP request failure. Extends :class:`.Error`.""" __slots__: tuple[str, ...] = ('message', 'status') @@ -54,7 +54,7 @@ def __repr__(self) -> str: class Ratelimited(Error): - """HTTP request failure due to the client being ratelimited. Because of this, the client is not allowed to make requests for an hour. Extends :class:`~.errors.Error`.""" + """HTTP request failure due to the client being ratelimited. Because of this, the client is not allowed to make requests for an hour. Extends :class:`.Error`.""" __slots__: tuple[str, ...] = ('retry_after',) diff --git a/topgg/models.py b/topgg/models.py index 820f7c10..b7b88f04 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -25,7 +25,7 @@ from datetime import datetime, timezone from typing import Optional, TypeVar -from collections.abc import Iterable +from enum import Enum T = TypeVar('T') @@ -43,12 +43,12 @@ def timestamp_from_id(id: int) -> datetime: class Voter: """A Top.gg voter.""" - __slots__: tuple[str, ...] = ('id', 'name', 'avatar') + __slots__: tuple[str, ...] = ('id', 'username', 'avatar') id: int """This voter's Discord ID.""" - name: str + username: str """This voter's username.""" avatar: str @@ -56,11 +56,11 @@ class Voter: def __init__(self, json: dict): self.id = int(json['id']) - self.name = json['username'] + self.username = json['username'] self.avatar = json['avatar'] def __repr__(self) -> str: - return f'<{__class__.__name__} id={self.id} name={self.name!r}>' + return f'<{__class__.__name__} id={self.id} username={self.username!r}>' def __int__(self) -> int: return self.id @@ -84,7 +84,7 @@ class Bot: __slots__: tuple[str, ...] = ( 'id', 'topgg_id', - 'name', + 'username', 'prefix', 'short_description', 'long_description', @@ -111,7 +111,7 @@ class Bot: topgg_id: int """This bot's Top.gg ID.""" - name: str + username: str """This bot's username.""" prefix: str @@ -171,7 +171,7 @@ class Bot: def __init__(self, json: dict): self.id = int(json['clientid']) self.topgg_id = int(json['id']) - self.name = json['username'] + self.username = json['username'] self.prefix = json['prefix'] self.short_description = json['shortdesc'] self.long_description = truthy_only(json.get('longdesc')) @@ -192,7 +192,7 @@ def __init__(self, json: dict): self.review_count = json['reviews']['count'] def __repr__(self) -> str: - return f'<{__class__.__name__} id={self.id} name={self.name!r} votes={self.votes} monthly_votes={self.monthly_votes} server_count={self.server_count}>' + return f'<{__class__.__name__} id={self.id} username={self.username!r} votes={self.votes} monthly_votes={self.monthly_votes} server_count={self.server_count}>' def __int__(self) -> int: return self.id @@ -210,179 +210,16 @@ def created_at(self) -> datetime: return timestamp_from_id(self.id) -class BotQuery: - """Configure a Discord bot query before sending it to the API.""" +class SortBy(Enum): + """Supported sorting criterias in :meth:`.Client.get_bots`.""" - __slots__: tuple[str, ...] = ('__client', '__params', '__search', '__sort') + __slots__: tuple[str, ...] = () - def __init__(self, client: object): - self.__client = client - self.__params = {} - self.__search = {} - self.__sort = None + ID = 'id' + """Sorts results based on each bot's ID.""" - def __repr__(self) -> str: - return f'<{__class__.__name__}>' - - def sort_by_id(self) -> 'BotQuery': - """ - Sorts results based on each bot's ID. - - :returns: The same object. This allows this object to have chained method calls. - :rtype: BotQuery - """ - - self.__sort = 'id' - - return self - - def sort_by_submission_date(self) -> 'BotQuery': - """ - Sorts results based on each bot's submission date. - - :returns: The same object. This allows this object to have chained method calls. - :rtype: BotQuery - """ - - self.__sort = 'date' - - return self - - def sort_by_monthly_votes(self) -> 'BotQuery': - """ - Sorts results based on each bot's monthly vote count. - - :returns: The same object. This allows this object to have chained method calls. - :rtype: BotQuery - """ - - self.__sort = 'monthlyPoints' - - return self - - def limit(self, limit: int) -> 'BotQuery': - """ - Sets the maximum amount of bots to be queried. - - :param id: The maximum amount of bots to be queried. This cannot be more than 500. - :type id: :py:class:`int` - - :returns: The same object. This allows this object to have chained method calls. - :rtype: BotQuery - """ - - self.__params['limit'] = max(min(limit, 500), 1) - - return self - - def skip(self, skip: int) -> 'BotQuery': - """ - Sets the amount of bots to be skipped. - - :param id: The amount of bots to be skipped. This cannot be more than 499. - :type id: :py:class:`int` - - :returns: The same object. This allows this object to have chained method calls. - :rtype: BotQuery - """ - - self.__params['offset'] = max(min(skip, 499), 0) - - return self - - def name(self, name: str) -> 'BotQuery': - """ - Queries only bots that has this username. - - :param id: The specified username. - :type id: :py:class:`str` - - :returns: The same object. This allows this object to have chained method calls. - :rtype: BotQuery - """ - - self.__search['username'] = name - - return self - - def prefix(self, prefix: str) -> 'BotQuery': - """ - Queries only bots that has this prefix. - - :param id: The specified prefix. - :type id: :py:class:`str` - - :returns: The same object. This allows this object to have chained method calls. - :rtype: BotQuery - """ - - self.__search['prefix'] = prefix - - return self - - def votes(self, votes: int) -> 'BotQuery': - """ - Queries only bots that has this vote count. - - :param id: The specified vote count. - :type id: :py:class:`int` - - :returns: The same object. This allows this object to have chained method calls. - :rtype: BotQuery - """ - - self.__search['points'] = max(votes, 0) - - return self - - def monthly_votes(self, monthly_votes: int) -> 'BotQuery': - """ - Queries only bots that has this monthly vote count. - - :param id: The specified monthly vote count. - :type id: :py:class:`int` - - :returns: The same object. This allows this object to have chained method calls. - :rtype: BotQuery - """ - - self.__search['monthlyPoints'] = max(monthly_votes, 0) - - return self - - def vanity(self, vanity: str) -> 'BotQuery': - """ - Queries only bots that has this Top.gg vanity URL. - - :param id: The specified Top.gg vanity URL (without the preceeding https://top.gg/). - :type id: :py:class:`str` - - :returns: The same object. This allows this object to have chained method calls. - :rtype: BotQuery - """ - - self.__search['vanity'] = vanity - - return self - - async def send(self) -> Iterable[Bot]: - """ - Sends the query to the API. - - :exception Error: The client is already closed. - :exception RequestError: Received a non-favorable response from the API. - :exception Ratelimited: Ratelimited from sending more requests. - - :returns: A generator of matching bots. - :rtype: Iterable[Bot] - """ - - params = self.__params.copy() - params['search'] = ' '.join(f'{k}: {v}' for k, v in self.__search.items()) - - if self.__sort: - params['sort'] = self.__sort - - bots = await self.__client._Client__request('GET', '/bots', params=params) + SUBMISSION_DATE = 'date' + """Sorts results based on each bot's submission date.""" - return map(Bot, bots.get('results', ())) + MONTHLY_VOTES = 'monthlyPoints' + """Sorts results based on each bot's monthly vote count.""" \ No newline at end of file diff --git a/topgg/webhooks.py b/topgg/webhooks.py index 34fabbb4..a31a63b7 100644 --- a/topgg/webhooks.py +++ b/topgg/webhooks.py @@ -100,12 +100,11 @@ def __init__(self, auth: Optional[str] = None, port: Optional[int] = None) -> No self.__running = False def __repr__(self) -> str: - return f'<{__class__.__name__} app={self.__app!r} running={self.__running}>' + return f'<{__class__.__name__} app={self.__app!r} running={self.running}>' def on_vote( self, route: str, - *, auth: Optional[str] = None, callback: Optional[OnVoteCallback] = None, ) -> Union[OnVoteCallback, OnVoteDecorator]: @@ -167,7 +166,7 @@ async def start(self, port: Optional[int] = None) -> None: :exception TypeError: the port is not provided either here or in the constructor call. """ - if not self.__running: + if not self.running: if port is None: port = self.__default_port @@ -187,13 +186,13 @@ async def close(self) -> None: Closes the webhook server. Has no effect if the server is already closed. """ - if self.__running: + if self.running: await self.__server.stop() self.__running = False @property - def is_running(self) -> bool: + def running(self) -> bool: """Whether the webhook server is running.""" return self.__running From d4497b8397324050b4f01378e5f989147c36e073 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 10 Mar 2025 12:29:42 +0700 Subject: [PATCH 107/126] refactor: remove bare excepts --- ruff.toml | 2 +- test.py | 11 ++++------- topgg/client.py | 50 ++++++++++++++++++++++++------------------------- topgg/models.py | 2 +- 4 files changed, 31 insertions(+), 34 deletions(-) diff --git a/ruff.toml b/ruff.toml index c1b92a19..e1b32c47 100644 --- a/ruff.toml +++ b/ruff.toml @@ -7,4 +7,4 @@ line-ending = "lf" quote-style = "single" [lint] -ignore = ["E402", "E722", "F401", "F403"] \ No newline at end of file +ignore = ["E402", "F401", "F403"] \ No newline at end of file diff --git a/test.py b/test.py index 1a80e11c..9272b7aa 100644 --- a/test.py +++ b/test.py @@ -41,16 +41,13 @@ def test_attributes(obj: object) -> None: async def run() -> None: async with topgg.Client(os.getenv('TOPGG_TOKEN')) as tg: - #bot = await tg.get_bot(432610292342587392) + bot = await tg.get_bot(432610292342587392) - #test_attributes(bot) + test_attributes(bot) - #await asyncio.sleep(1) + await asyncio.sleep(1) bots = await tg.get_bots( - limit=250, - offset=50, - username='Shiro', - sort_by=topgg.SortBy.MONTHLY_VOTES + limit=250, offset=50, username='Shiro', sort_by=topgg.SortBy.MONTHLY_VOTES ) for b in bots: diff --git a/topgg/client.py b/topgg/client.py index fd93ef08..81c017f2 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -23,15 +23,16 @@ SOFTWARE. """ +from aiohttp import ClientResponseError, ClientSession, ClientTimeout from collections.abc import Iterable, Callable, Coroutine -from aiohttp import ClientSession, ClientTimeout from typing import Any, Optional, Union from collections import namedtuple from inspect import isawaitable from base64 import b64decode -from json import loads from time import time +import binascii import asyncio +import json from .ratelimiter import Ratelimiter, RatelimiterManager from .errors import Error, RequestError, Ratelimited @@ -95,8 +96,8 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): encoded_json = token.split('.')[1] encoded_json += '=' * (4 - (len(encoded_json) % 4)) - int(loads(b64decode(encoded_json))['id']) - except: + int(json.loads(b64decode(encoded_json))['id']) + except (IndexError, ValueError, binascii.Error, json.decoder.JSONDecodeError): raise ValueError('Got a malformed API token.') endpoint_ratelimits = namedtuple('EndpointRatelimits', 'global_ bot') @@ -123,7 +124,7 @@ async def __request( method: str, path: str, params: Optional[dict] = None, - json: Optional[dict] = None, + body: Optional[dict] = None, ) -> dict: if self.__session.closed: raise Error('Client session is already closed.') @@ -144,15 +145,15 @@ async def __request( kwargs = {} - if json: - kwargs['json'] = json + if body: + kwargs['json'] = body if params: kwargs['params'] = params status = None retry_after = None - json = None + output = None async with ratelimiter: try: @@ -169,15 +170,16 @@ async def __request( status = resp.status retry_after = float(resp.headers.get('Retry-After', 0)) - try: - json = await resp.json() - except: - pass + if 'application/json' in resp.headers['Content-Type']: + try: + output = await resp.json() + except json.decoder.JSONDecodeError: + pass resp.raise_for_status() - return json - except: + return output + except ClientResponseError: if status == 429: if retry_after > MAXIMUM_DELAY_THRESHOLD: self.__current_ratelimit = time() + retry_after @@ -188,7 +190,7 @@ async def __request( return await self.__request(method, path) - raise RequestError(json and json.get('message'), status) from None + raise RequestError(output and output.get('message'), status) from None async def get_bot(self, id: int) -> Bot: """ @@ -215,7 +217,7 @@ async def get_bots( sort_by: Optional[SortBy] = None, votes: Optional[int] = None, monthly_votes: Optional[int] = None, - **search: Optional[str] + **search: Optional[str], ) -> Iterable[Bot]: """ Fetches and yields Discord bots that matches the specified query. @@ -249,19 +251,21 @@ async def get_bots( 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_by is not None: if not isinstance(sort_by, SortBy): - raise TypeError(f'Expected sort_by to be a SortBy enum, got {sort_by.__class__.__name__}.') + raise TypeError( + f'Expected sort_by to be a SortBy enum, got {sort_by.__class__.__name__}.' + ) params['sort'] = sort_by.value if votes is not None: search['points'] = max(votes, 0) - + if monthly_votes is not None: search['monthlyPoints'] = max(monthly_votes, 0) @@ -305,9 +309,7 @@ async def post_server_count(self, new_server_count: int) -> None: f'Posted server count cannot be zero or lower, got {new_server_count}.' ) - await self.__request( - 'POST', '/bots/stats', json={'server_count': new_server_count} - ) + await self.__request('POST', '/bots/stats', body={'server_count': new_server_count}) async def is_weekend(self) -> bool: """ @@ -342,9 +344,7 @@ async def get_voters(self, page: int = 1) -> Iterable[Voter]: return map( Voter, - await self.__request( - 'GET', '/bots/votes', params={'page': max(page, 1)} - ), + await self.__request('GET', '/bots/votes', params={'page': max(page, 1)}), ) async def has_voted(self, id: int) -> bool: diff --git a/topgg/models.py b/topgg/models.py index b7b88f04..96edf66b 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -222,4 +222,4 @@ class SortBy(Enum): """Sorts results based on each bot's submission date.""" MONTHLY_VOTES = 'monthlyPoints' - """Sorts results based on each bot's monthly vote count.""" \ No newline at end of file + """Sorts results based on each bot's monthly vote count.""" From 322a58e318731a1257ce1ce9534cb216b050a21e Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 10 Mar 2025 12:31:01 +0700 Subject: [PATCH 108/126] meta: remove ignored lint rules --- ruff.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ruff.toml b/ruff.toml index e1b32c47..08bb4b3a 100644 --- a/ruff.toml +++ b/ruff.toml @@ -7,4 +7,4 @@ line-ending = "lf" quote-style = "single" [lint] -ignore = ["E402", "F401", "F403"] \ No newline at end of file +ignore = ["E402"] \ No newline at end of file From c4ff91caf9f6be4492eaffd55c8c6a980e602b27 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 17 Mar 2025 23:18:50 +0700 Subject: [PATCH 109/126] feat: rename RatelimiterManager to Ratelimiters --- topgg/client.py | 4 ++-- topgg/ratelimiter.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index 81c017f2..c43e592e 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -34,7 +34,7 @@ import asyncio import json -from .ratelimiter import Ratelimiter, RatelimiterManager +from .ratelimiter import Ratelimiter, Ratelimiters from .errors import Error, RequestError, Ratelimited from .models import Bot, SortBy, Voter from .version import VERSION @@ -105,7 +105,7 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): self.__ratelimiters = endpoint_ratelimits( global_=Ratelimiter(99, 1), bot=Ratelimiter(59, 60) ) - self.__ratelimiter_manager = RatelimiterManager(self.__ratelimiters) + self.__ratelimiter_manager = Ratelimiters(self.__ratelimiters) self.__current_ratelimit = None self.__autopost_task = None diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index a7cb7e5d..a82ae5e5 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -71,13 +71,13 @@ def _timespan(self) -> float: return self.__calls[-1] - self.__calls[0] -class RatelimiterManager: +class Ratelimiters: __slots__: tuple[str, ...] = ('__ratelimiters',) def __init__(self, ratelimiters: Iterable[Ratelimiter]): self.__ratelimiters = ratelimiters - async def __aenter__(self) -> 'RatelimiterManager': + async def __aenter__(self) -> 'Ratelimiters': for manager in self.__ratelimiters: await manager.__aenter__() From ac4b280f7b2f1c804beb8b6759cf27d1ee9ad03e Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 20 Mar 2025 21:10:08 +0700 Subject: [PATCH 110/126] style: rename ratelimiter_manager to ratelimiters --- topgg/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index c43e592e..ef502db8 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -74,7 +74,7 @@ class Client: '__session', '__token', '__ratelimiters', - '__ratelimiter_manager', + '__ratelimiters', '__current_ratelimit', '__autopost_task', '__autopost_retrieval_callback', @@ -105,7 +105,7 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): self.__ratelimiters = endpoint_ratelimits( global_=Ratelimiter(99, 1), bot=Ratelimiter(59, 60) ) - self.__ratelimiter_manager = Ratelimiters(self.__ratelimiters) + self.__ratelimiters = Ratelimiters(self.__ratelimiters) self.__current_ratelimit = None self.__autopost_task = None @@ -138,7 +138,7 @@ async def __request( self.__current_ratelimit = None ratelimiter = ( - self.__ratelimiter_manager + self.__ratelimiters if path.startswith('/bots') else self.__ratelimiters.global_ ) From ad148241c5588dc22cc3b4c11baec21e7cfc2717 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 20 Mar 2025 22:02:23 +0700 Subject: [PATCH 111/126] doc: add proper documentation for the ratelimiters --- topgg/client.py | 4 +--- topgg/ratelimiter.py | 24 ++++++++++++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index ef502db8..8323d123 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -138,9 +138,7 @@ async def __request( self.__current_ratelimit = None ratelimiter = ( - self.__ratelimiters - if path.startswith('/bots') - else self.__ratelimiters.global_ + self.__ratelimiters if path.startswith('/bots') else self.__ratelimiters.global_ ) kwargs = {} diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index a82ae5e5..44beb05f 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -31,6 +31,8 @@ class Ratelimiter: + """Handles ratelimits for a specific endpoint.""" + __slots__: tuple[str, ...] = ('__lock', '__max_calls', '__period', '__calls') def __init__( @@ -44,6 +46,8 @@ def __init__( self.__lock = asyncio.Lock() async def __aenter__(self) -> 'Ratelimiter': + """Delays the request to this endpoint if it could lead to a ratelimit.""" + async with self.__lock: if len(self.__calls) >= self.__max_calls: until = time() + self.__period - self._timespan @@ -59,8 +63,9 @@ async def __aexit__( _exc_val: BaseException, _exc_tb: TracebackType, ) -> None: + """Stores the previous request's timestamp.""" + async with self.__lock: - # Store the last operation timestamp. self.__calls.append(time()) while self._timespan >= self.__period: @@ -68,18 +73,24 @@ async def __aexit__( @property def _timespan(self) -> float: + """The timespan between the first call and last call.""" + return self.__calls[-1] - self.__calls[0] class Ratelimiters: + """Handles ratelimits for multiple endpoints.""" + __slots__: tuple[str, ...] = ('__ratelimiters',) def __init__(self, ratelimiters: Iterable[Ratelimiter]): self.__ratelimiters = ratelimiters async def __aenter__(self) -> 'Ratelimiters': - for manager in self.__ratelimiters: - await manager.__aenter__() + """Delays the request to this endpoint if it could lead to a ratelimit.""" + + for ratelimiter in self.__ratelimiters: + await ratelimiter.__aenter__() return self @@ -89,6 +100,11 @@ async def __aexit__( exc_val: BaseException, exc_tb: TracebackType, ) -> None: + """Stores the previous request's timestamp.""" + await asyncio.gather( - *(manager.__aexit__(exc_type, exc_val, exc_tb) for manager in self.__ratelimiters) + *( + ratelimiter.__aexit__(exc_type, exc_val, exc_tb) + for ratelimiter in self.__ratelimiters + ) ) From 349c5f30e7def82206d8705fb94c7cbb88c56f22 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 28 Apr 2025 17:12:38 +0700 Subject: [PATCH 112/126] feat: remove banner url --- topgg/models.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/topgg/models.py b/topgg/models.py index 96edf66b..5482bc94 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -92,7 +92,6 @@ class Bot: 'website', 'github', 'owners', - 'banner_url', 'submitted_at', 'votes', 'monthly_votes', @@ -135,9 +134,6 @@ class Bot: owners: list[int] """This bot's owner IDs.""" - banner_url: Optional[str] - """This bot's banner URL.""" - submitted_at: datetime """This bot's submission date.""" @@ -179,7 +175,6 @@ def __init__(self, json: dict): self.website = truthy_only(json.get('website')) self.github = truthy_only(json.get('github')) self.owners = [int(id) for id in json['owners']] - self.banner_url = truthy_only(json.get('bannerUrl')) self.submitted_at = datetime.fromisoformat(json['date'].replace('Z', '+00:00')) self.votes = json['points'] self.monthly_votes = json['monthlyPoints'] From 6a54c1a26b3fc3bb384113d5d200d0c0e43ba5ac Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 28 Apr 2025 17:13:06 +0700 Subject: [PATCH 113/126] deps: bump aiohttp dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9fdbe824..44f7fe5d 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.11.13"] +dependencies = ["aiohttp>=3.11.18"] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", From d4604c6f6407d9e43a163e2934fd0b4238a9ec87 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 5 May 2025 22:20:22 +0700 Subject: [PATCH 114/126] feat: remove url --- topgg/models.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/topgg/models.py b/topgg/models.py index 5482bc94..8c48cf8d 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -97,7 +97,6 @@ class Bot: 'monthly_votes', 'support', 'avatar', - 'url', 'invite', 'server_count', 'review_score', @@ -149,12 +148,12 @@ class Bot: avatar: str """This bot's avatar URL.""" - url: str - """This bot's Top.gg page URL.""" - invite: Optional[str] """This bot's invite URL.""" + vanity: Optional[str] + """This bot's Top.gg vanity code.""" + server_count: Optional[str] """This bot's posted server count.""" @@ -180,8 +179,8 @@ def __init__(self, json: dict): self.monthly_votes = json['monthlyPoints'] self.support = truthy_only(json.get('support')) self.avatar = json['avatar'] - self.url = f'https://top.gg/bot/{json.get("vanity") or self.topgg_id}' self.invite = truthy_only(json.get('invite')) + self.vanity = truthy_only(json.get('vanity')) self.server_count = json.get('server_count') self.review_score = json['reviews']['averageScore'] self.review_count = json['reviews']['count'] From 77cc550b288c4aa6cb54c87e88c9d6905ecabaf5 Mon Sep 17 00:00:00 2001 From: null8626 Date: Fri, 23 May 2025 13:04:16 +0700 Subject: [PATCH 115/126] fix: fix missing vanity slot + GET /bots/votes not working --- topgg/client.py | 18 ++++++++++++------ topgg/models.py | 1 + 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/topgg/client.py b/topgg/client.py index 8323d123..e334435d 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -69,17 +69,21 @@ class Client: :exception ValueError: ``token`` is not a valid API token. """ + id: int + """This bot's ID.""" + __slots__: tuple[str, ...] = ( '__own_session', '__session', '__token', - '__ratelimiters', + '__ratelimiter', '__ratelimiters', '__current_ratelimit', '__autopost_task', '__autopost_retrieval_callback', '__autopost_success_callbacks', '__autopost_error_callbacks', + 'id', ) def __init__(self, token: str, *, session: Optional[ClientSession] = None): @@ -96,16 +100,16 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): encoded_json = token.split('.')[1] encoded_json += '=' * (4 - (len(encoded_json) % 4)) - int(json.loads(b64decode(encoded_json))['id']) + self.id = int(json.loads(b64decode(encoded_json))['id']) except (IndexError, ValueError, binascii.Error, json.decoder.JSONDecodeError): raise ValueError('Got a malformed API token.') endpoint_ratelimits = namedtuple('EndpointRatelimits', 'global_ bot') - self.__ratelimiters = endpoint_ratelimits( + self.__ratelimiter = endpoint_ratelimits( global_=Ratelimiter(99, 1), bot=Ratelimiter(59, 60) ) - self.__ratelimiters = Ratelimiters(self.__ratelimiters) + self.__ratelimiters = Ratelimiters(self.__ratelimiter) self.__current_ratelimit = None self.__autopost_task = None @@ -138,7 +142,7 @@ async def __request( self.__current_ratelimit = None ratelimiter = ( - self.__ratelimiters if path.startswith('/bots') else self.__ratelimiters.global_ + self.__ratelimiters if path.startswith('/bots') else self.__ratelimiter.global_ ) kwargs = {} @@ -342,7 +346,9 @@ async def get_voters(self, page: int = 1) -> Iterable[Voter]: return map( Voter, - await self.__request('GET', '/bots/votes', params={'page': max(page, 1)}), + await self.__request( + 'GET', f'/bots/{self.id}/votes', params={'page': max(page, 1)} + ), ) async def has_voted(self, id: int) -> bool: diff --git a/topgg/models.py b/topgg/models.py index 8c48cf8d..1ef0c412 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -98,6 +98,7 @@ class Bot: 'support', 'avatar', 'invite', + 'vanity', 'server_count', 'review_score', 'review_count', From e12cd77e1f24c03bcb9aa5c0dac0aef6f1ae2697 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 27 May 2025 16:39:16 +0700 Subject: [PATCH 116/126] deps: bump aiohttp version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 44f7fe5d..eeca7f45 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.11.18"] +dependencies = ["aiohttp>=3.12.2"] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", From 4465df894ba38b45e14c1ed1c052bab58064eff0 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 16 Jun 2025 15:47:29 +0700 Subject: [PATCH 117/126] feat: remove search parameter in anticipation of v1 --- README.md | 1 - docs/index.rst | 1 - test.py | 2 +- topgg/client.py | 21 --------------------- 4 files changed, 1 insertion(+), 24 deletions(-) diff --git a/README.md b/README.md index 794efbb0..7d4c2a53 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,6 @@ async def main() -> None: bots = await tg.get_bots( limit=250, offset=50, - username='Shiro', sort_by=topgg.SortBy.MONTHLY_VOTES ) diff --git a/docs/index.rst b/docs/index.rst index d34a16b3..fc2cd0c9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -49,7 +49,6 @@ Basic examples bots = await tg.get_bots( limit=250, offset=50, - username='Shiro', sort_by=topgg.SortBy.MONTHLY_VOTES ) diff --git a/test.py b/test.py index 9272b7aa..a1f4e360 100644 --- a/test.py +++ b/test.py @@ -47,7 +47,7 @@ async def run() -> None: await asyncio.sleep(1) bots = await tg.get_bots( - limit=250, offset=50, username='Shiro', sort_by=topgg.SortBy.MONTHLY_VOTES + limit=250, offset=50, sort_by=topgg.SortBy.MONTHLY_VOTES ) for b in bots: diff --git a/topgg/client.py b/topgg/client.py index e334435d..b81de255 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -217,9 +217,6 @@ async def get_bots( limit: Optional[int] = None, offset: Optional[int] = None, sort_by: Optional[SortBy] = None, - votes: Optional[int] = None, - monthly_votes: Optional[int] = None, - **search: Optional[str], ) -> Iterable[Bot]: """ Fetches and yields Discord bots that matches the specified query. @@ -230,16 +227,6 @@ async def get_bots( :type offset: Optional[:py:class:`int`] :param sort_by: Sorts results based on a specific criteria. Results will always be descending. :type sort_by: Optional[:class:`.SortBy`] - :param username: Queries only bots that has this username. - :type username: Optional[:py:class:`str`] - :param prefix: Queries only bots that has this prefix. - :type prefix: Optional[:py:class:`str`] - :param votes: Queries only bots that has this vote count. - :type votes: Optional[:py:class:`int`] - :param monthly_votes: Queries only bots that has this monthly vote count. - :type monthly_votes: Optional[:py:class:`int`] - :param vanity: Queries only bots that has this Top.gg vanity code. - :type vanity: Optional[:py:class:`str`] :exception Error: The client is already closed. :exception RequestError: Received a non-favorable response from the API. @@ -265,14 +252,6 @@ async def get_bots( params['sort'] = sort_by.value - if votes is not None: - search['points'] = max(votes, 0) - - if monthly_votes is not None: - search['monthlyPoints'] = max(monthly_votes, 0) - - params['search'] = ' '.join(f'{k}: {v}' for k, v in search.items()) - bots = await self.__request('GET', '/bots', params=params) return map(Bot, bots.get('results', ())) From 9cabb37f465b6687506a8e6a8f2b8e42ca7350cf Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 16 Jun 2025 19:37:43 +0700 Subject: [PATCH 118/126] style: prettier --- test.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test.py b/test.py index a1f4e360..2897c95f 100644 --- a/test.py +++ b/test.py @@ -46,9 +46,7 @@ async def run() -> None: test_attributes(bot) await asyncio.sleep(1) - bots = await tg.get_bots( - limit=250, offset=50, sort_by=topgg.SortBy.MONTHLY_VOTES - ) + bots = await tg.get_bots(limit=250, offset=50, sort_by=topgg.SortBy.MONTHLY_VOTES) for b in bots: test_attributes(b) From 563e48ac460415017d92efb7f2e8418d4a2c37f5 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 17 Jun 2025 01:35:12 +0700 Subject: [PATCH 119/126] feat: add widgets --- docs/client.rst | 3 +++ topgg/__init__.py | 2 ++ topgg/client.py | 2 +- topgg/widget.py | 14 ++++++++++++++ 4 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 topgg/widget.py diff --git a/docs/client.rst b/docs/client.rst index 022df0fa..091bcff3 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -4,6 +4,9 @@ Client reference .. autoclass:: topgg.client.Client :members: +.. automodule:: topgg.widget + :members: + .. autoclass:: topgg.models.SortBy() :members: diff --git a/topgg/__init__.py b/topgg/__init__.py index 3e696dbb..75fac9b9 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -28,6 +28,7 @@ from .webhooks import Vote, Webhooks from .version import VERSION from .client import Client +from . import widget __title__ = 'topggpy' @@ -49,4 +50,5 @@ 'Vote', 'Voter', 'Webhooks', + 'widget', ) diff --git a/topgg/client.py b/topgg/client.py index b81de255..c6b920e8 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/v1' MAXIMUM_DELAY_THRESHOLD = 5.0 diff --git a/topgg/widget.py b/topgg/widget.py new file mode 100644 index 00000000..95817838 --- /dev/null +++ b/topgg/widget.py @@ -0,0 +1,14 @@ +from .client import BASE_URL + +def large(id: int) -> str: + """ + Generates a large widget URL. + + :param id: The requested ID. + :type id: :py:class:`int` + + :returns: The widget URL. + :rtype: :py:class:`str` + """ + + return f'{BASE_URL}/widgets/large/{id}' \ No newline at end of file From 0cebb35c88d5e31fe715d044c916058de1236e4c Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 18 Jun 2025 20:35:20 +0700 Subject: [PATCH 120/126] feat: add small widgets --- topgg/__init__.py | 2 ++ topgg/widget.py | 66 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/topgg/__init__.py b/topgg/__init__.py index 75fac9b9..f8f8677e 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -26,6 +26,7 @@ from .errors import Error, RequestError, Ratelimited from .models import Bot, SortBy, Voter from .webhooks import Vote, Webhooks +from .widget import WidgetType from .version import VERSION from .client import Client from . import widget @@ -51,4 +52,5 @@ 'Voter', 'Webhooks', 'widget', + 'WidgetType', ) diff --git a/topgg/widget.py b/topgg/widget.py index 95817838..d424da9d 100644 --- a/topgg/widget.py +++ b/topgg/widget.py @@ -1,9 +1,71 @@ from .client import BASE_URL -def large(id: int) -> str: +import enum + + +class WidgetType(enum.Enum): + """ + Widget type. + """ + + DISCORD_BOT = 'discord/bot' + DISCORD_SERVER = 'discord/server' + + +def large(type: WidgetType, id: int) -> str: """ Generates a large widget URL. + :param type: The widget type. + :type type: :class:`.WidgetType` + :param id: The requested ID. + :type id: :py:class:`int` + + :returns: The widget URL. + :rtype: :py:class:`str` + """ + + return f'{BASE_URL}/widgets/large/{type.value}/{id}' + + +def votes(type: WidgetType, id: int) -> str: + """ + Generates a small widget URL for displaying votes. + + :param type: The widget type. + :type type: :class:`.WidgetType` + :param id: The requested ID. + :type id: :py:class:`int` + + :returns: The widget URL. + :rtype: :py:class:`str` + """ + + return f'{BASE_URL}/widgets/small/votes/{type.value}/{id}' + + +def owner(type: WidgetType, id: int) -> str: + """ + Generates a small widget URL for displaying an entity's owner. + + :param type: The widget type. + :type type: :class:`.WidgetType` + :param id: The requested ID. + :type id: :py:class:`int` + + :returns: The widget URL. + :rtype: :py:class:`str` + """ + + return f'{BASE_URL}/widgets/small/owner/{type.value}/{id}' + + +def social(type: WidgetType, id: int) -> str: + """ + Generates a small widget URL for displaying social stats. + + :param type: The widget type. + :type type: :class:`.WidgetType` :param id: The requested ID. :type id: :py:class:`int` @@ -11,4 +73,4 @@ def large(id: int) -> str: :rtype: :py:class:`str` """ - return f'{BASE_URL}/widgets/large/{id}' \ No newline at end of file + return f'{BASE_URL}/widgets/small/social/{type.value}/{id}' From 446a654109984b922d450454ca80ef47572b3920 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 23 Jun 2025 23:33:21 +0700 Subject: [PATCH 121/126] docs: readme overhaul --- README.md | 221 ++++++++++++++++++++++++++++------------------ topgg/client.py | 7 +- topgg/webhooks.py | 26 +++--- 3 files changed, 150 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 7d4c2a53..d9bd3e41 100644 --- a/README.md +++ b/README.md @@ -1,132 +1,183 @@ -# [topgg][pypi-url] [![pypi][pypi-image]][pypi-url] [![downloads][downloads-image]][pypi-url] +# Top.gg Python SDK -[pypi-image]: https://img.shields.io/pypi/v/topggpy.svg?style=flat-square -[pypi-url]: https://pypi.org/project/topggpy/ -[downloads-image]: https://img.shields.io/pypi/dm/topggpy?style=flat-square +The community-maintained Python library for Top.gg. -A simple API wrapper for [Top.gg](https://top.gg) written in Python. +## Installation -## Getting started +```sh +$ pip install topggpy +``` -Make sure you already have an API token handy. See [this tutorial](https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff) on how to retrieve it. +## Setting up -After that, run the following command in your terminal: +### Automatic cleanup -```console -$ pip install topggpy -``` +```py +import topgg -## Basic examples +import os -For more information, please read the [documentation](https://topggpy.readthedocs.io/en/latest/). +async with topgg.Client(os.getenv('TOPGG_TOKEN')) as client: + # ... +``` + +### Manual cleanup ```py -# Import the module. import topgg -import asyncio import os +client = topgg.Client(os.getenv('TOPGG_TOKEN')) -async def main() -> None: +# ... - # Declare the client. - async with topgg.Client(os.getenv('TOPGG_TOKEN')) as tg: - - # Fetch a bot from its ID. - bot = await tg.get_bot(432610292342587392) +await client.close() +``` - print(bot) +## Usage - # Fetch bots that matches the specified query. - bots = await tg.get_bots( - limit=250, - offset=50, - sort_by=topgg.SortBy.MONTHLY_VOTES - ) +### Getting a bot - for b in bots: - print(b) +```py +bot = await client.get_bot(432610292342587392) +``` - # Post your bot's server count to the API. This will update the server count in your bot's Top.gg page. - await tg.post_server_count(2) +### Getting several bots - # Fetch your bot's posted server count. - posted_server_count = await tg.get_server_count() +#### With defaults - # Fetch your bot's last 1000 unique voters. - voters = await tg.get_voters() +```py +bots = await client.get_bots() - for voter in voters: - print(voter) +for bot in bots: + print(bot) +``` - # Check if a user has voted your bot. - has_voted = await tg.has_voted(661200758510977084) +#### With explicit arguments - if has_voted: - print('This user has voted!') +```py +bots = await client.get_bots(limit=250, offset=50, sort_by=topgg.SortBy.MONTHLY_VOTES) - # Check if the weekend multiplier is active, where a single vote counts as two. - is_weekend = await tg.is_weekend() +for bot in bots: + print(bot) +``` - if is_weekend: - print('The weekend multiplier is active!') +### Getting your bot's voters +#### First page -if __name__ == '__main__': - - # See https://stackoverflow.com/questions/45600579/asyncio-event-loop-is-closed-when-getting-loop - # for more details. - if os.name == 'nt': - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - - asyncio.run(main()) +```py +voters = await client.get_voters() + +for voter in voters: + print(voter) ``` -## Autoposting example +#### Subsequent pages ```py -# Import the module. -import topgg +voters = await client.get_voters(2) -import asyncio -import os +for voter in voters: + print(voter) +``` +### Check if a user has voted for your bot -async def main() -> None: +```py +has_voted = await client.has_voted(661200758510977084) +``` - # Declare the client. - tg = topgg.Client(os.getenv('TOPGG_TOKEN')) +### Getting your bot's server count - # Callback to retrieve server count data (required). - @tg.autopost_retrieval - def get_server_count() -> int: - return 2 +```py +posted_server_count = await client.get_server_count() +``` - # Callback upon successful server count autoposting (optional). - @tg.autopost_success - def success(server_count: int) -> None: - print(f'Successfully posted {server_count} servers to the API!') +### Posting your bot's server count + +```py +await client.post_server_count(bot.server_count) +``` + +### Automatically posting your bot's server count every few minutes + +```py +@client.autopost_retrieval +def get_server_count() -> int: + return bot.server_count - # Error handler upon HTTP-related posting failure (optional). - @tg.autopost_error - def error(error: topgg.Error) -> None: - print(f'Error: {error!r}') +@client.autopost_success +def success(server_count: int) -> None: + print(f'Successfully posted {server_count} servers to the API!') - # Start the autoposter. - tg.start_autoposter() +@client.autopost_error +def error(error: topgg.Error) -> None: + print(f'Error: {error!r}') - # Your other logic here... +client.start_autoposter() - # Client session cleanup while also implicitly calling tg.stop_autoposter(). - await tg.close() +# ... + +client.stop_autoposter() # Optional +``` + +### Checking if the weekend vote multiplier is active + +```py +is_weekend = await client.is_weekend() +``` + +### Generating widget URLs + +#### Large + +```py +widget_url = topgg.widget.large(topgg.WidgetType.DISCORD_BOT, 574652751745777665) +``` + +#### Votes + +```py +widget_url = topgg.widget.votes(topgg.WidgetType.DISCORD_BOT, 574652751745777665) +``` + +#### Owner + +```py +widget_url = topgg.widget.owner(topgg.WidgetType.DISCORD_BOT, 574652751745777665) +``` + +#### Social + +```py +widget_url = topgg.widget.social(topgg.WidgetType.DISCORD_BOT, 574652751745777665) +``` + +### Webhooks + +#### Being notified whenever someone voted for your bot + +```py +import topgg + +import asyncio +import os + +port = 8080 +secret = os.getenv('MY_TOPGG_WEBHOOK_SECRET') + +webhooks = topgg.Webhooks(secret, port) + +@webhooks.on_vote('/votes') +def voted(vote: topgg.Vote) -> None: + print(f'A user with the ID of {vote.voter_id} has voted us on Top.gg!') + +async def main() -> None: + await webhooks.start() # Starts the server + await asyncio.Event().wait() # Keeps the server alive through indefinite blocking if __name__ == '__main__': - - # See https://stackoverflow.com/questions/45600579/asyncio-event-loop-is-closed-when-getting-loop - # for more details. - if os.name == 'nt': - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - asyncio.run(main()) ``` \ No newline at end of file diff --git a/topgg/client.py b/topgg/client.py index c6b920e8..9a723fbe 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -102,7 +102,7 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): self.id = int(json.loads(b64decode(encoded_json))['id']) except (IndexError, ValueError, binascii.Error, json.decoder.JSONDecodeError): - raise ValueError('Got a malformed API token.') + raise ValueError('Got a malformed API token.') from None endpoint_ratelimits = namedtuple('EndpointRatelimits', 'global_ bot') @@ -473,8 +473,9 @@ def start_autoposter(self, interval: Optional[float] = None) -> None: """ if not self.autoposter_running: - if self.__autopost_retrieval_callback is None: - raise TypeError('Missing autopost_retrieval callback.') + assert ( + self.__autopost_retrieval_callback is not None + ), 'Missing autopost_retrieval callback.' self.__autopost_task = asyncio.create_task(self.__autopost_loop(interval)) diff --git a/topgg/webhooks.py b/topgg/webhooks.py index a31a63b7..fed49781 100644 --- a/topgg/webhooks.py +++ b/topgg/webhooks.py @@ -36,7 +36,7 @@ class Vote: """A dispatched Top.gg vote event.""" - __slots__ = ('receiver_id', 'voter_id', 'is_server', 'is_test', 'is_weekend', 'query') + __slots__ = ('receiver_id', 'voter_id', 'is_test', 'is_weekend', 'query') receiver_id: int """The ID of the Discord bot/server that received a vote.""" @@ -44,9 +44,6 @@ class Vote: voter_id: int """The ID of the Top.gg user who voted.""" - is_server: bool - """Whether this vote's receiver is a Discord server.""" - is_test: bool """Whether this vote is just a test done from the page settings.""" @@ -57,9 +54,10 @@ class Vote: """Query strings found on the vote page.""" def __init__(self, json: dict): - self.receiver_id = int(json.get('bot', json['guild'])) + guild = json.get('guild') + + self.receiver_id = int(json.get('bot', guild)) self.voter_id = int(json['user']) - self.is_server = bool(json.get('guild')) self.is_test = json['type'] == 'test' self.is_weekend = bool(json.get('isWeekend')) @@ -124,14 +122,12 @@ def on_vote( :rtype: Union[:data:`~.webhooks.OnVoteCallback`, :data:`~.webhooks.OnVoteDecorator`] """ - if not isinstance(route, str): + if not isinstance(route, str) or not route: raise TypeError('Missing route argument.') - if auth is None: - auth = self.__default_auth + auth = auth or self.__default_auth - if auth is None: - raise TypeError('Missing password.') + assert auth is not None, 'Missing password.' def decorator(inner_callback: OnVoteCallback) -> RawCallback: async def handler(request: web.Request) -> web.Response: @@ -143,7 +139,7 @@ async def handler(request: web.Request) -> web.Response: if isawaitable(response): await response - return web.Response(status=200, text='OK') + return web.Response(status=204, text='') self.__app.router.add_post(route, handler) @@ -167,11 +163,9 @@ async def start(self, port: Optional[int] = None) -> None: """ if not self.running: - if port is None: - port = self.__default_port + port = port or self.__default_port - if port is None: - raise TypeError('Missing port.') + assert port is not None, 'Missing port.' runner = web.AppRunner(self.__app) await runner.setup() From fffb66fdce0af6613b35da784d922f6535a9a8e5 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 24 Jun 2025 18:54:03 +0700 Subject: [PATCH 122/126] feat: add 400 checks --- README.md | 5 +---- topgg/webhooks.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d9bd3e41..0656121d 100644 --- a/README.md +++ b/README.md @@ -165,10 +165,7 @@ import topgg import asyncio import os -port = 8080 -secret = os.getenv('MY_TOPGG_WEBHOOK_SECRET') - -webhooks = topgg.Webhooks(secret, port) +webhooks = topgg.Webhooks(os.getenv('MY_TOPGG_WEBHOOK_SECRET'), 8080) @webhooks.on_vote('/votes') def voted(vote: topgg.Vote) -> None: diff --git a/topgg/webhooks.py b/topgg/webhooks.py index fed49781..bb02dfed 100644 --- a/topgg/webhooks.py +++ b/topgg/webhooks.py @@ -24,10 +24,11 @@ """ from collections.abc import Awaitable, Callable +from aiohttp import web, ContentTypeError from typing import Any, Optional, Union +from json import JSONDecodeError from inspect import isawaitable from urllib import parse -from aiohttp import web RawCallback = Callable[[web.Request], Awaitable[web.StreamResponse]] @@ -134,7 +135,12 @@ async def handler(request: web.Request) -> web.Response: if request.headers.get('Authorization', '') != auth: return web.Response(status=401, text='Unauthorized') - response = inner_callback(Vote(await request.json())) + try: + vote = Vote(await request.json()) + except (JSONDecodeError, ContentTypeError): + return web.Response(status=400, text='Bad request') + + response = inner_callback(vote) if isawaitable(response): await response From 39cc433e89479725f2a76dd5233901084c1c37d3 Mon Sep 17 00:00:00 2001 From: null8626 Date: Fri, 27 Jun 2025 15:49:38 +0700 Subject: [PATCH 123/126] feat: move Vote class --- README.md | 2 +- docs/data.rst | 3 +++ docs/webhooks.rst | 3 --- topgg/__init__.py | 4 ++-- topgg/models.py | 42 ++++++++++++++++++++++++++++++++++++++++++ topgg/webhooks.py | 46 ++-------------------------------------------- 6 files changed, 50 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 0656121d..abc7a88c 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ def get_server_count() -> int: @client.autopost_success def success(server_count: int) -> None: - print(f'Successfully posted {server_count} servers to the API!') + print(f'Successfully posted {server_count} servers to Top.gg!') @client.autopost_error def error(error: topgg.Error) -> None: diff --git a/docs/data.rst b/docs/data.rst index 848f3611..c5b7ba30 100644 --- a/docs/data.rst +++ b/docs/data.rst @@ -4,5 +4,8 @@ Data reference .. autoclass:: topgg.models.Bot() :members: +.. autoclass:: topgg.models.Vote() + :members: + .. autoclass:: topgg.models.Voter() :members: \ No newline at end of file diff --git a/docs/webhooks.rst b/docs/webhooks.rst index 9d21cdb5..ed5ce54e 100644 --- a/docs/webhooks.rst +++ b/docs/webhooks.rst @@ -4,8 +4,5 @@ Webhooks reference .. autoclass:: topgg.webhooks.Webhooks :members: -.. autoclass:: topgg.webhooks.Vote() - :members: - .. autodata:: topgg.webhooks.OnVoteCallback .. autodata:: topgg.webhooks.OnVoteDecorator \ No newline at end of file diff --git a/topgg/__init__.py b/topgg/__init__.py index f8f8677e..423e5010 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -24,8 +24,8 @@ """ from .errors import Error, RequestError, Ratelimited -from .models import Bot, SortBy, Voter -from .webhooks import Vote, Webhooks +from .models import Bot, SortBy, Vote, Voter +from .webhooks import Webhooks from .widget import WidgetType from .version import VERSION from .client import Client diff --git a/topgg/models.py b/topgg/models.py index 1ef0c412..479eb923 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -25,6 +25,7 @@ from datetime import datetime, timezone from typing import Optional, TypeVar +from urllib import parse from enum import Enum @@ -40,6 +41,47 @@ def timestamp_from_id(id: int) -> datetime: return datetime.fromtimestamp(((id >> 22) + 1420070400000) // 1000, tz=timezone.utc) +class Vote: + """A dispatched Top.gg vote event.""" + + __slots__ = ('receiver_id', 'voter_id', 'is_test', 'is_weekend', 'query') + + receiver_id: int + """The ID of the Discord bot/server that received a vote.""" + + voter_id: int + """The ID of the Top.gg user who voted.""" + + is_test: bool + """Whether this vote is just a test done from the page settings.""" + + is_weekend: bool + """Whether the weekend multiplier is active, where a single vote counts as two.""" + + query: dict[str, str] + """Query strings found on the vote page.""" + + def __init__(self, json: dict): + guild = json.get('guild') + + self.receiver_id = int(json.get('bot', guild)) + self.voter_id = int(json['user']) + self.is_test = json['type'] == 'test' + self.is_weekend = bool(json.get('isWeekend')) + + if query := json.get('query'): + self.query = { + k: v[0] for k, v in parse.parse_qs(parse.urlsplit(query).query).items() + } + else: + self.query = {} + + def __repr__(self) -> str: + return ( + f'<{__class__.__name__} receiver_id={self.receiver_id} voter_id={self.voter_id}>' + ) + + class Voter: """A Top.gg voter.""" diff --git a/topgg/webhooks.py b/topgg/webhooks.py index bb02dfed..7b7e1ff2 100644 --- a/topgg/webhooks.py +++ b/topgg/webhooks.py @@ -28,53 +28,11 @@ from typing import Any, Optional, Union from json import JSONDecodeError from inspect import isawaitable -from urllib import parse - -RawCallback = Callable[[web.Request], Awaitable[web.StreamResponse]] - - -class Vote: - """A dispatched Top.gg vote event.""" - - __slots__ = ('receiver_id', 'voter_id', 'is_test', 'is_weekend', 'query') - - receiver_id: int - """The ID of the Discord bot/server that received a vote.""" - - voter_id: int - """The ID of the Top.gg user who voted.""" - - is_test: bool - """Whether this vote is just a test done from the page settings.""" - - is_weekend: bool - """Whether the weekend multiplier is active, where a single vote counts as two.""" - - query: dict[str, str] - """Query strings found on the vote page.""" - - def __init__(self, json: dict): - guild = json.get('guild') - - self.receiver_id = int(json.get('bot', guild)) - self.voter_id = int(json['user']) - self.is_test = json['type'] == 'test' - self.is_weekend = bool(json.get('isWeekend')) - - if query := json.get('query'): - self.query = { - k: v[0] for k, v in parse.parse_qs(parse.urlsplit(query).query).items() - } - else: - self.query = {} - - def __repr__(self) -> str: - return ( - f'<{__class__.__name__} receiver_id={self.receiver_id} voter_id={self.voter_id}>' - ) +from .models import Vote +RawCallback = Callable[[web.Request], Awaitable[web.StreamResponse]] OnVoteCallback = Callable[[Vote], Any] OnVoteDecorator = Callable[[OnVoteCallback], RawCallback] From 6e5229a7587f129979b3a6690785f189ad1c9a37 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 9 Jul 2025 15:22:11 +0700 Subject: [PATCH 124/126] doc: documentation overhaul --- README.md | 24 ++- docs/_static/style.css | 28 +++- docs/client.rst | 9 +- docs/index.rst | 347 ++++++++++++++++++++++++----------------- pyproject.toml | 2 +- topgg/__init__.py | 2 +- topgg/client.py | 157 ++++++++++++++++--- topgg/errors.py | 8 +- topgg/models.py | 22 +-- topgg/webhooks.py | 32 +++- topgg/widget.py | 24 +++ 11 files changed, 455 insertions(+), 200 deletions(-) diff --git a/README.md b/README.md index abc7a88c..b4e656b6 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,23 @@ The community-maintained Python library for Top.gg. +## Chapters + +- [Installation](#installation) +- [Setting up](#setting-up) +- [Usage](#usage) + - [Getting a bot](#getting-a-bot) + - [Getting several bots](#getting-several-bots) + - [Getting your bot's voters](#getting-your-bots-voters) + - [Check if a user has voted for your bot](#check-if-a-user-has-voted-for-your-bot) + - [Getting your bot's server count](#getting-your-bots-server-count) + - [Posting your bot's server count](#posting-your-bots-server-count) + - [Automatically posting your bot's server count every few minutes](#automatically-posting-your-bots-server-count-every-few-minutes) + - [Checking if the weekend vote multiplier is active](#checking-if-the-weekend-vote-multiplier-is-active) + - [Generating widget URLs](#generating-widget-urls) + - [Webhooks](#webhooks) + - [Being notified whenever someone voted for your bot](#being-notified-whenever-someone-voted-for-your-bot) + ## Installation ```sh @@ -10,24 +27,26 @@ $ pip install topggpy ## Setting up -### Automatic cleanup +### Implicit cleanup ```py import topgg import os + async with topgg.Client(os.getenv('TOPGG_TOKEN')) as client: # ... ``` -### Manual cleanup +### Explicit cleanup ```py import topgg import os + client = topgg.Client(os.getenv('TOPGG_TOKEN')) # ... @@ -165,6 +184,7 @@ import topgg import asyncio import os + webhooks = topgg.Webhooks(os.getenv('MY_TOPGG_WEBHOOK_SECRET'), 8080) @webhooks.on_vote('/votes') diff --git a/docs/_static/style.css b/docs/_static/style.css index ffaa30dd..944637a6 100644 --- a/docs/_static/style.css +++ b/docs/_static/style.css @@ -27,12 +27,30 @@ aside.toc-drawer .docutils:hover, .sidebar-brand-text:hover { filter: opacity(75%); } -.highlight .c1, em { +.highlight *, em { font-style: normal !important; + text-decoration: none !important; + font-weight: normal !important; } -.highlight .nn { - text-decoration: none !important; +.sig-paren, span.p, :not(.sig-name) > span.pre { + font-weight: normal !important; +} + +:not(h1) > a.reference { + text-decoration: underline; +} + +:not(h1) > a.reference:hover { + text-decoration: none; +} + +.field-even p strong, .field-odd p strong { + font-family: var(--font-stack--monospace); +} + +h1 > a.reference:hover { + text-decoration: underline; } h1 { @@ -41,8 +59,4 @@ h1 { .sidebar-brand-text { font-weight: bolder; -} - -.sidebar-scroll .reference.internal { - color: var(--color-brand-primary); } \ No newline at end of file diff --git a/docs/client.rst b/docs/client.rst index 091bcff3..a92ffe45 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -4,11 +4,14 @@ Client reference .. autoclass:: topgg.client.Client :members: -.. automodule:: topgg.widget +.. autoclass:: topgg.widget.WidgetType() :members: + :undoc-members: -.. autoclass:: topgg.models.SortBy() - :members: +.. autofunction:: topgg.widget.large +.. autofunction:: topgg.widget.owner +.. autofunction:: topgg.widget.social +.. autofunction:: topgg.widget.votes .. autoclass:: topgg.errors.Error() diff --git a/docs/index.rst b/docs/index.rst index fc2cd0c9..18542c9c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,142 +1,211 @@ -======== -topggpy_ -======== - -|pypi|_ |downloads|_ - -.. _topggpy: https://pypi.org/project/topggpy/ -.. |pypi| image:: https://img.shields.io/pypi/v/topggpy.svg?style=flat-square -.. _pypi: https://pypi.org/project/topggpy/ -.. |downloads| image:: https://img.shields.io/pypi/dm/topggpy?style=flat-square -.. _downloads: https://pypi.org/project/topggpy/ - -A simple API wrapper for `Top.gg `_ written in Python. - -Getting started ---------------- - -Make sure you already have an API token handy. See `this tutorial `_ on how to retrieve it. - -After that, run the following command in your terminal: - -.. code-block:: console - - $ pip install topggpy - -Basic examples --------------- - -.. code-block:: python - - # Import the module. - import topgg - - import asyncio - import os - - - async def main() -> None: - - # Declare the client. - async with topgg.Client(os.getenv('TOPGG_TOKEN')) as tg: - - # Fetch a bot from its ID. - bot = await tg.get_bot(432610292342587392) - - print(bot) - - # Fetch bots that matches the specified query. - bots = await tg.get_bots( - limit=250, - offset=50, - sort_by=topgg.SortBy.MONTHLY_VOTES - ) - - for b in bots: - print(b) - - # Post your bot's server count to the API. This will update the server count in your bot's Top.gg page. - await tg.post_server_count(2) - - # Fetch your bot's posted server count. - posted_server_count = await tg.get_server_count() - - # Fetch your bot's last 1000 unique voters. - voters = await tg.get_voters() - - for voter in voters: - print(voter) - - # Check if a user has voted your bot. - has_voted = await tg.has_voted(661200758510977084) - - if has_voted: - print('This user has voted!') - - # Check if the weekend multiplier is active, where a single vote counts as two. - is_weekend = await tg.is_weekend() - - if is_weekend: - print('The weekend multiplier is active!') - - - if __name__ == '__main__': - - # See https://stackoverflow.com/questions/45600579/asyncio-event-loop-is-closed-when-getting-loop - # for more details. - if os.name == 'nt': - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - - asyncio.run(main()) - -Autoposting example -------------------- - -.. code-block:: python - - # Import the module. - import topgg - - import asyncio - import os - - - async def main() -> None: - - # Declare the client. - tg = topgg.Client(os.getenv('TOPGG_TOKEN')) - - # Callback to retrieve server count data (required). - @tg.autopost_retrieval - def get_server_count() -> int: - return 2 - - # Callback upon successful server count autoposting (optional). - @tg.autopost_success - def success(server_count: int) -> None: - print(f'Successfully posted {server_count} servers to the API!') - - # Error handler upon HTTP-related posting failure (optional). - @tg.autopost_error - def error(error: topgg.Error) -> None: - print(f'Error: {error!r}') - - # Start the autoposter. - tg.start_autoposter() - - # Your other logic here... - - # Client session cleanup while also implicitly calling tg.stop_autoposter(). - await tg.close() - - if __name__ == '__main__': - - # See https://stackoverflow.com/questions/45600579/asyncio-event-loop-is-closed-when-getting-loop - # for more details. - if os.name == 'nt': - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - - asyncio.run(main()) +Top.gg Python SDK +================= + +The community-maintained Python library for Top.gg. + +Installation +------------ + +.. code-block:: shell + + $ pip install topggpy + +Setting up +---------- + +Implicit cleanup +~~~~~~~~~~~~~~~~ + +.. code-block:: python + + import topgg + + import os + + + async with topgg.Client(os.getenv('TOPGG_TOKEN')) as client: + # ... + +Explicit cleanup +~~~~~~~~~~~~~~~~ + +.. code-block:: python + + import topgg + + import os + + + client = topgg.Client(os.getenv('TOPGG_TOKEN')) + + # ... + + await client.close() + +Usage +----- + +Getting a bot +~~~~~~~~~~~~~ + +.. code-block:: python + + bot = await client.get_bot(432610292342587392) + +Getting several bots +~~~~~~~~~~~~~~~~~~~~ + +With defaults +^^^^^^^^^^^^^ + +.. code-block:: python + + bots = await client.get_bots() + + for bot in bots: + print(bot) + +With explicit arguments +^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + bots = await client.get_bots(limit=250, offset=50, sort_by=topgg.SortBy.MONTHLY_VOTES) + + for bot in bots: + print(bot) + +Getting your bot's voters +~~~~~~~~~~~~~~~~~~~~~~~~~ + +First page +^^^^^^^^^^ + +.. code-block:: python + + voters = await client.get_voters() + + for voter in voters: + print(voter) + +Subsequent pages +^^^^^^^^^^^^^^^^ + +.. code-block:: python + + voters = await client.get_voters(2) + + for voter in voters: + print(voter) + +Check if a user has voted for your bot +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + has_voted = await client.has_voted(661200758510977084) + +Getting your bot's server count +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + posted_server_count = await client.get_server_count() + +Posting your bot's server count +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + await client.post_server_count(bot.server_count) + +Automatically posting your bot's server count every few minutes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + @client.autopost_retrieval + def get_server_count() -> int: + return bot.server_count + + @client.autopost_success + def success(server_count: int) -> None: + print(f'Successfully posted {server_count} servers to Top.gg!') + + @client.autopost_error + def error(error: topgg.Error) -> None: + print(f'Error: {error!r}') + + client.start_autoposter() + + # ... + + client.stop_autoposter() # Optional + +Checking if the weekend vote multiplier is active +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + is_weekend = await client.is_weekend() + +Generating widget URLs +~~~~~~~~~~~~~~~~~~~~~~ + +Large +^^^^^ + +.. code-block:: python + + widget_url = topgg.widget.large(topgg.WidgetType.DISCORD_BOT, 574652751745777665) + +Votes +^^^^^ + +.. code-block:: python + + widget_url = topgg.widget.votes(topgg.WidgetType.DISCORD_BOT, 574652751745777665) + +Owner +^^^^^ + +.. code-block:: python + + widget_url = topgg.widget.owner(topgg.WidgetType.DISCORD_BOT, 574652751745777665) + +Social +^^^^^^ + +.. code-block:: python + + widget_url = topgg.widget.social(topgg.WidgetType.DISCORD_BOT, 574652751745777665) + +Webhooks +~~~~~~~~ + +Being notified whenever someone voted for your bot +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + import topgg + + import asyncio + import os + + + webhooks = topgg.Webhooks(os.getenv('MY_TOPGG_WEBHOOK_SECRET'), 8080) + + @webhooks.on_vote('/votes') + def voted(vote: topgg.Vote) -> None: + print(f'A user with the ID of {vote.voter_id} has voted us on Top.gg!') + + async def main() -> None: + await webhooks.start() # Starts the server + await asyncio.Event().wait() # Keeps the server alive through indefinite blocking + + if __name__ == '__main__': + asyncio.run(main()) .. toctree:: :maxdepth: 2 diff --git a/pyproject.toml b/pyproject.toml index eeca7f45..0f9d2e42 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.12.2"] +dependencies = ["aiohttp>=3.12.13"] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", diff --git a/topgg/__init__.py b/topgg/__init__.py index 423e5010..d803f751 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -25,8 +25,8 @@ from .errors import Error, RequestError, Ratelimited from .models import Bot, SortBy, Vote, Voter -from .webhooks import Webhooks from .widget import WidgetType +from .webhooks import Webhooks from .version import VERSION from .client import Client from . import widget diff --git a/topgg/client.py b/topgg/client.py index 9a723fbe..a2eb31ce 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -60,7 +60,22 @@ class Client: """ Interact with the API's endpoints. - :param token: The API token to use. To retrieve it, see https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff. + Examples: + + .. code-block:: python + + # Explicit cleanup + client = topgg.Client(os.getenv('TOPGG_TOKEN')) + + # ... + + await client.close() + + # Implicit cleanup + async with topgg.Client(os.getenv('TOPGG_TOKEN')) as client: + # ... + + :param token: Your Top.gg API token. To retrieve it, see https://github.com/top-gg-community/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff. :type token: :py:class:`str` :param session: Whether to use an existing :class:`~aiohttp.ClientSession` for requesting or not. Defaults to :py:obj:`None` (creates a new one instead) :type session: Optional[:class:`~aiohttp.ClientSession`] @@ -198,7 +213,13 @@ async def get_bot(self, id: int) -> Bot: """ Fetches a Discord bot from its ID. - :param id: The requested ID. + Example: + + .. code-block:: python + + bot = await client.get_bot(432610292342587392) + + :param id: The bot's ID. :type id: :py:class:`int` :exception Error: The client is already closed. @@ -219,20 +240,33 @@ async def get_bots( sort_by: Optional[SortBy] = None, ) -> Iterable[Bot]: """ - Fetches and yields Discord bots that matches the specified query. + 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_by=topgg.SortBy.MONTHLY_VOTES) - :param limit: The maximum amount of bots to be queried. + 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_by: Sorts results based on a specific criteria. Results will always be descending. + :param sort_by: The criteria to sort results by. Results will always be descending. :type sort_by: Optional[:class:`.SortBy`] :exception Error: The client is already closed. :exception RequestError: Received a non-favorable response from the API. :exception Ratelimited: Ratelimited from sending more requests. - :returns: A generator of matching bots. + :returns: The requested bots. :rtype: Iterable[:class:`.Bot`] """ @@ -260,11 +294,17 @@ async def get_server_count(self) -> Optional[int]: """ Fetches your Discord bot's posted server count. + Example: + + .. code-block:: python + + posted_server_count = await client.get_server_count() + :exception Error: The client is already closed. :exception RequestError: Received a non-favorable response from the API. :exception Ratelimited: Ratelimited from sending more requests. - :returns: The posted server count. This can be :py:obj:`None` if it does not exist. + :returns: The posted server count. :rtype: Optional[:py:class:`int`] """ @@ -274,9 +314,15 @@ async def get_server_count(self) -> Optional[int]: async def post_server_count(self, new_server_count: int) -> None: """ - Posts your Discord bot's server count to the API. This will update the server count in your bot's Top.gg page. + Updates the server count in your Discord bot's Top.gg page. + + Example: + + .. code-block:: python + + await client.post_server_count(bot.server_count) - :param new_server_count: The new server count to post. This cannot be zero. + :param new_server_count: The updated server count. This cannot be zero. :type new_server_count: :py:class:`int` :exception ValueError: The new_server_count argument is zero or lower. @@ -296,6 +342,12 @@ async def is_weekend(self) -> bool: """ Checks if the weekend multiplier is active, where a single vote counts as two. + Example: + + .. code-block:: python + + is_weekend = await client.is_weekend() + :exception Error: The client is already closed. :exception RequestError: Received a non-favorable response from the API. :exception Ratelimited: Ratelimited from sending more requests. @@ -310,7 +362,20 @@ async def is_weekend(self) -> bool: async def get_voters(self, page: int = 1) -> Iterable[Voter]: """ - Fetches and yields your Discord bot's recent 100 unique voters. + Fetches your Discord bot's recent unique voters. + + Examples: + + .. code-block:: python + + # First page + voters = await client.get_voters() + + # Subsequent pages + voters = await client.get_voters(2) + + for voter in voters: + print(voter) :param page: The page number. Each page can only have at most 100 voters. Defaults to 1. :type page: :py:class:`int` @@ -319,7 +384,7 @@ async def get_voters(self, page: int = 1) -> Iterable[Voter]: :exception RequestError: Received a non-favorable response from the API. :exception Ratelimited: Ratelimited from sending more requests. - :returns: A generator of your bot's recent unique voters. + :returns: The requested voters. :rtype: Iterable[:class:`.Voter`] """ @@ -332,16 +397,22 @@ async def get_voters(self, page: int = 1) -> Iterable[Voter]: async def has_voted(self, id: int) -> bool: """ - Checks if the specified Discord user has voted your Discord bot. + Checks if a Discord user has voted for your Discord bot in the past 12 hours. + + Example: + + .. code-block:: python - :param id: The requested user's ID. + has_voted = await client.has_voted(661200758510977084) + + :param id: The user's ID. :type id: :py:class:`int` :exception Error: The client is already closed. :exception RequestError: The specified user has not logged in to Top.gg or the client has received other non-favorable responses from the API. :exception Ratelimited: Ratelimited from sending more requests. - :returns: Whether the specified user has voted your bot. + :returns: Whether the user has voted in the past 12 hours. :rtype: :py:class:`bool` """ @@ -385,7 +456,15 @@ def autopost_retrieval( self, callback: Optional[AutopostRetrievalCallback] = None ) -> Union[AutopostRetrievalCallback, AutopostRetrievalDecorator]: """ - Registers an autopost server count retrieval callback. This callback is required for autoposting. + Registers an autopost server count retrieval callback. + + Example: + + .. code-block:: python + + @client.autopost_retrieval + def get_server_count() -> int: + return bot.server_count :param callback: The autopost server count retrieval callback. This can be asynchronous or synchronous, as long as it eventually returns an :py:class:`int`. :type callback: Optional[:data:`~.client.AutopostRetrievalCallback`] @@ -410,7 +489,15 @@ def autopost_success( self, callback: Optional[AutopostSuccessCallback] = None ) -> Union[AutopostSuccessCallback, AutopostSuccessDecorator]: """ - Adds an autopost on success callback. Several callbacks are possible. + Registers an autopost on success callback. Several callbacks are possible. + + Example: + + .. code-block:: python + + @client.autopost_success + def success(server_count: int) -> None: + print(f'Successfully posted {server_count} servers to Top.gg!') :param callback: The autopost on success callback. This can be asynchronous or synchronous, as long as it accepts a :py:class:`int` argument for the posted server count. :type callback: Optional[:data:`~.client.AutopostSuccessCallback`] @@ -435,7 +522,15 @@ def autopost_error( self, callback: Optional[AutopostErrorCallback] = None ) -> Union[AutopostErrorCallback, AutopostErrorDecorator]: """ - Adds an autopost on error handler. Several callbacks are possible. + Registers an autopost on error handler. Several callbacks are possible. + + Example: + + .. code-block:: python + + @client.autopost_error + def error(error: topgg.Error) -> None: + print(f'Error: {error!r}') :param callback: The autopost on error handler. This can be asynchronous or synchronous, as long as it accepts an :class:`.Error` argument for the request exception. :type callback: Optional[:data:`~.client.AutopostErrorCallback`] @@ -464,9 +559,15 @@ def autoposter_running(self) -> bool: def start_autoposter(self, interval: Optional[float] = None) -> None: """ - Starts the autoposter. Has no effect if the autoposter is already running. + Starts the autoposter, which automatically updates the server count in your Discord bot's Top.gg page every few minutes. + + Example: + + .. code-block:: python - :param interval: The interval between posting in seconds. Defaults to 15 minutes. + client.start_autoposter() + + :param interval: The delay between updates in seconds. :type interval: Optional[:py:class:`float`] :exception TypeError: The server count retrieval callback does not exist. @@ -481,7 +582,13 @@ def start_autoposter(self, interval: Optional[float] = None) -> None: def stop_autoposter(self) -> None: """ - Stops the autoposter. Has no effect if the autoposter is already stopped. + Stops the autoposter. + + Example: + + .. code-block:: python + + client.stop_autoposter() """ if self.autoposter_running: @@ -489,7 +596,15 @@ def stop_autoposter(self) -> None: self.__autopost_task = None async def close(self) -> None: - """Closes the client. Nothing will happen if the client uses a pre-existing :class:`~aiohttp.ClientSession` or if the session is already closed.""" + """ + Closes the client. + + Example: + + .. code-block:: python + + await client.close() + """ self.stop_autoposter() diff --git a/topgg/errors.py b/topgg/errors.py index 2f534c24..995c13c6 100644 --- a/topgg/errors.py +++ b/topgg/errors.py @@ -27,7 +27,7 @@ class Error(Exception): - """The base error class. Extends :py:class:`Exception`.""" + """An error coming from this SDK. Extends :py:class:`Exception`.""" __slots__: tuple[str, ...] = () @@ -54,18 +54,18 @@ def __repr__(self) -> str: class Ratelimited(Error): - """HTTP request failure due to the client being ratelimited. Because of this, the client is not allowed to make requests for an hour. Extends :class:`.Error`.""" + """Ratelimited from sending more requests. Extends :class:`.Error`.""" __slots__: tuple[str, ...] = ('retry_after',) retry_after: float - """How long the client should wait (in seconds) before it can make a request to the API again.""" + """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 by the API for an hour. Please try again in {retry_after} seconds.' + f'Blocked from sending more requests, try again in {retry_after} seconds.' ) def __repr__(self) -> str: diff --git a/topgg/models.py b/topgg/models.py index 479eb923..96f94173 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -23,8 +23,8 @@ SOFTWARE. """ -from datetime import datetime, timezone from typing import Optional, TypeVar +from datetime import datetime from urllib import parse from enum import Enum @@ -37,10 +37,6 @@ def truthy_only(value: Optional[T]) -> Optional[T]: return value -def timestamp_from_id(id: int) -> datetime: - return datetime.fromtimestamp(((id >> 22) + 1420070400000) // 1000, tz=timezone.utc) - - class Vote: """A dispatched Top.gg vote event.""" @@ -50,7 +46,7 @@ class Vote: """The ID of the Discord bot/server that received a vote.""" voter_id: int - """The ID of the Top.gg user who voted.""" + """The ID of the Discord user who voted.""" is_test: bool """Whether this vote is just a test done from the page settings.""" @@ -113,12 +109,6 @@ def __eq__(self, other: 'Voter') -> bool: return NotImplemented - @property - def created_at(self) -> datetime: - """This voter's creation date.""" - - return timestamp_from_id(self.id) - class Bot: """A Discord bot listed on Top.gg.""" @@ -162,7 +152,7 @@ class Bot: """This bot's short description.""" long_description: Optional[str] - """This bot's long description. This can contain HTML and/or Markdown.""" + """This bot's HTML/Markdown long description.""" tags: list[str] """This bot's tags.""" @@ -240,12 +230,6 @@ def __eq__(self, other: 'Bot') -> bool: return NotImplemented - @property - def created_at(self) -> datetime: - """This bot's creation date.""" - - return timestamp_from_id(self.id) - class SortBy(Enum): """Supported sorting criterias in :meth:`.Client.get_bots`.""" diff --git a/topgg/webhooks.py b/topgg/webhooks.py index 7b7e1ff2..2dad4ac3 100644 --- a/topgg/webhooks.py +++ b/topgg/webhooks.py @@ -41,6 +41,12 @@ class Webhooks: """ Receive events from the Top.gg servers. + Example: + + .. code-block:: python + + webhooks = topgg.Webhooks(os.getenv('MY_TOPGG_WEBHOOK_SECRET'), 8080) + :param auth: The default password to use. :type auth: Optional[:py:class:`str`] :param port: The default port to use. @@ -66,7 +72,15 @@ def on_vote( callback: Optional[OnVoteCallback] = None, ) -> Union[OnVoteCallback, OnVoteDecorator]: """ - Registers a handler to receive whenever your Discord bot/server receives a vote. + Registers a route that gets called whenever your Discord bot/server receives a vote. + + Example: + + .. code-block:: python + + @webhooks.on_vote('/votes') + def voted(vote: topgg.Vote) -> None: + print(f'A user with the ID of {vote.voter_id} has voted us on Top.gg!') :param route: The route to use. :type route: :py:class:`str` @@ -118,7 +132,13 @@ async def handler(request: web.Request) -> web.Response: async def start(self, port: Optional[int] = None) -> None: """ - Starts the webhook server. Has no effect if the server is already running. + Starts the webhook server. + + Example: + + .. code-block:: python + + await webhooks.start() :param port: The port to override and use. Defaults to the default port provided in the constructor call. :type port: Optional[:py:class:`int`] @@ -141,7 +161,13 @@ async def start(self, port: Optional[int] = None) -> None: async def close(self) -> None: """ - Closes the webhook server. Has no effect if the server is already closed. + Closes the webhook server. + + Example: + + .. code-block:: python + + await webhooks.close() """ if self.running: diff --git a/topgg/widget.py b/topgg/widget.py index d424da9d..3e439ad9 100644 --- a/topgg/widget.py +++ b/topgg/widget.py @@ -16,6 +16,12 @@ def large(type: WidgetType, id: int) -> str: """ Generates a large widget URL. + Example: + + .. code-block:: python + + widget_url = topgg.widget.large(topgg.WidgetType.DISCORD_BOT, 574652751745777665) + :param type: The widget type. :type type: :class:`.WidgetType` :param id: The requested ID. @@ -32,6 +38,12 @@ def votes(type: WidgetType, id: int) -> str: """ Generates a small widget URL for displaying votes. + Example: + + .. code-block:: python + + widget_url = topgg.widget.votes(topgg.WidgetType.DISCORD_BOT, 574652751745777665) + :param type: The widget type. :type type: :class:`.WidgetType` :param id: The requested ID. @@ -48,6 +60,12 @@ def owner(type: WidgetType, id: int) -> str: """ Generates a small widget URL for displaying an entity's owner. + Example: + + .. code-block:: python + + widget_url = topgg.widget.owner(topgg.WidgetType.DISCORD_BOT, 574652751745777665) + :param type: The widget type. :type type: :class:`.WidgetType` :param id: The requested ID. @@ -64,6 +82,12 @@ def social(type: WidgetType, id: int) -> str: """ Generates a small widget URL for displaying social stats. + Example: + + .. code-block:: python + + widget_url = topgg.widget.social(topgg.WidgetType.DISCORD_BOT, 574652751745777665) + :param type: The widget type. :type type: :class:`.WidgetType` :param id: The requested ID. From 642164e89d3872dc1e8a66ca6651b8ffd9d24c91 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 28 Aug 2025 10:50:54 +0700 Subject: [PATCH 125/126] deps: bump aiohttp version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0f9d2e42..a82f67be 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.12.13"] +dependencies = ["aiohttp>=3.12.15"] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", From 6251a1bd4972aac4006b6f2dec35579ec7ce99e9 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 8 Sep 2025 18:42:17 +0700 Subject: [PATCH 126/126] [feat,docs]: adapt methods and documentation to v1 --- README.md | 78 ++++++++++--- docs/client.rst | 16 ++- docs/data.rst | 3 + docs/index.rst | 89 +++++++++++---- test.py | 12 +- test_autoposter.py | 8 +- topgg/__init__.py | 4 +- topgg/client.py | 276 ++++++++++++++++++++++++++++++++------------- topgg/models.py | 55 ++++++++- topgg/webhooks.py | 10 +- topgg/widget.py | 10 +- 11 files changed, 414 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index b4e656b6..a9f1c418 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,16 @@ The community-maintained Python library for Top.gg. - [Usage](#usage) - [Getting a bot](#getting-a-bot) - [Getting several bots](#getting-several-bots) - - [Getting your bot's voters](#getting-your-bots-voters) - - [Check if a user has voted for your bot](#check-if-a-user-has-voted-for-your-bot) + - [Getting your project's voters](#getting-your-projects-voters) + - [Getting your project's vote information of a user](#getting-your-projects-vote-information-of-a-user) - [Getting your bot's server count](#getting-your-bots-server-count) - [Posting your bot's server count](#posting-your-bots-server-count) + - [Posting your bot's application commands list](#posting-your-bots-application-commands-list) - [Automatically posting your bot's server count every few minutes](#automatically-posting-your-bots-server-count-every-few-minutes) - [Checking if the weekend vote multiplier is active](#checking-if-the-weekend-vote-multiplier-is-active) - [Generating widget URLs](#generating-widget-urls) - [Webhooks](#webhooks) - - [Being notified whenever someone voted for your bot](#being-notified-whenever-someone-voted-for-your-bot) + - [Being notified whenever someone voted for your project](#being-notified-whenever-someone-voted-for-your-project) ## Installation @@ -34,7 +35,6 @@ import topgg import os - async with topgg.Client(os.getenv('TOPGG_TOKEN')) as client: # ... ``` @@ -46,7 +46,6 @@ import topgg import os - client = topgg.Client(os.getenv('TOPGG_TOKEN')) # ... @@ -82,7 +81,7 @@ for bot in bots: print(bot) ``` -### Getting your bot's voters +### Getting your project's voters #### First page @@ -102,44 +101,88 @@ for voter in voters: print(voter) ``` -### Check if a user has voted for your bot +### Getting your project's vote information of a user + +#### Discord ID ```py -has_voted = await client.has_voted(661200758510977084) +vote = await client.get_vote(661200758510977084) + +if vote: + print(f'User has voted: {vote!r}') +``` + +#### Top.gg ID + +```py +vote = await client.get_vote(8226924471638491136, source=topgg.UserSource.TOPGG) + +if vote: + print(f'User has voted: {vote!r}') ``` ### Getting your bot's server count ```py -posted_server_count = await client.get_server_count() +posted_server_count = await client.get_bot_server_count() ``` ### Posting your bot's server count ```py -await client.post_server_count(bot.server_count) +await client.post_bot_server_count(bot.server_count) +``` + +### Posting your bot's application commands list + +#### Discord.py/Pycord/Nextcord/Disnake + +```py +app_id = bot.user.id +commands = await bot.http.get_global_commands(app_id) + +await client.post_bot_commands(commands) +``` + +#### Hikari + +```py +app_id = ... +commands = await bot.rest.request('GET', f'/applications/{app_id}/commands') + +await client.post_bot_commands(commands) +``` + +#### Discord.http + +```py +http = discordhttp.HTTP(f'BOT {os.getenv("BOT_TOKEN")}') +app_id = ... +commands = await http.get(f'/applications/{app_id}/commands') + +await client.post_bot_commands(commands) ``` ### Automatically posting your bot's server count every few minutes ```py -@client.autopost_retrieval +@client.bot_autopost_retrieval def get_server_count() -> int: return bot.server_count -@client.autopost_success +@client.bot_autopost_success def success(server_count: int) -> None: print(f'Successfully posted {server_count} servers to Top.gg!') -@client.autopost_error +@client.bot_autopost_error def error(error: topgg.Error) -> None: print(f'Error: {error!r}') -client.start_autoposter() +client.start_bot_autoposter() # ... -client.stop_autoposter() # Optional +client.stop_bot_autoposter() # Optional ``` ### Checking if the weekend vote multiplier is active @@ -176,7 +219,7 @@ widget_url = topgg.widget.social(topgg.WidgetType.DISCORD_BOT, 57465275174577766 ### Webhooks -#### Being notified whenever someone voted for your bot +#### Being notified whenever someone voted for your project ```py import topgg @@ -184,11 +227,10 @@ import topgg import asyncio import os - webhooks = topgg.Webhooks(os.getenv('MY_TOPGG_WEBHOOK_SECRET'), 8080) @webhooks.on_vote('/votes') -def voted(vote: topgg.Vote) -> None: +def voted(vote: topgg.VoteEvent) -> None: print(f'A user with the ID of {vote.voter_id} has voted us on Top.gg!') async def main() -> None: diff --git a/docs/client.rst b/docs/client.rst index a92ffe45..12e5b345 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -4,6 +4,10 @@ Client reference .. autoclass:: topgg.client.Client :members: +.. autoclass:: topgg.models.UserSource() + :members: + :undoc-members: + .. autoclass:: topgg.widget.WidgetType() :members: :undoc-members: @@ -21,11 +25,11 @@ Client reference .. autoclass:: topgg.errors.Ratelimited() :members: -.. autodata:: topgg.client.AutopostRetrievalCallback -.. autodata:: topgg.client.AutopostRetrievalDecorator +.. autodata:: topgg.client.BotAutopostRetrievalCallback +.. autodata:: topgg.client.BotAutopostRetrievalDecorator -.. autodata:: topgg.client.AutopostSuccessCallback -.. autodata:: topgg.client.AutopostSuccessDecorator +.. autodata:: topgg.client.BotAutopostSuccessCallback +.. autodata:: topgg.client.BotAutopostSuccessDecorator -.. autodata:: topgg.client.AutopostErrorCallback -.. autodata:: topgg.client.AutopostErrorDecorator \ No newline at end of file +.. autodata:: topgg.client.BotAutopostErrorCallback +.. autodata:: topgg.client.BotAutopostErrorDecorator \ No newline at end of file diff --git a/docs/data.rst b/docs/data.rst index c5b7ba30..18f8483b 100644 --- a/docs/data.rst +++ b/docs/data.rst @@ -7,5 +7,8 @@ Data reference .. autoclass:: topgg.models.Vote() :members: +.. autoclass:: topgg.models.VoteEvent() + :members: + .. autoclass:: topgg.models.Voter() :members: \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 18542c9c..b9aba7f3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,7 +22,6 @@ Implicit cleanup import os - async with topgg.Client(os.getenv('TOPGG_TOKEN')) as client: # ... @@ -35,7 +34,6 @@ Explicit cleanup import os - client = topgg.Client(os.getenv('TOPGG_TOKEN')) # ... @@ -75,8 +73,8 @@ With explicit arguments for bot in bots: print(bot) -Getting your bot's voters -~~~~~~~~~~~~~~~~~~~~~~~~~ +Getting your project’s voters +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ First page ^^^^^^^^^^ @@ -98,49 +96,99 @@ Subsequent pages for voter in voters: print(voter) -Check if a user has voted for your bot -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Getting your project’s vote information of a user +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Discord ID +^^^^^^^^^^ .. code-block:: python - has_voted = await client.has_voted(661200758510977084) + vote = await client.get_vote(661200758510977084) + + if vote: + print(f'User has voted: {vote!r}') -Getting your bot's server count +Top.gg ID +^^^^^^^^^ + +.. code-block:: python + + vote = await client.get_vote(8226924471638491136, source=topgg.UserSource.TOPGG) + + if vote: + print(f'User has voted: {vote!r}') + +Getting your bot’s server count ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python - posted_server_count = await client.get_server_count() + posted_server_count = await client.get_bot_server_count() -Posting your bot's server count +Posting your bot’s server count ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python - await client.post_server_count(bot.server_count) + await client.post_bot_server_count(bot.server_count) + +Posting your bot’s application commands list +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Discord.py/Pycord/Nextcord/Disnake +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python -Automatically posting your bot's server count every few minutes + app_id = bot.user.id + commands = await bot.http.get_global_commands(app_id) + + await client.post_bot_commands(commands) + +Hikari +^^^^^^ + +.. code-block:: python + + app_id = ... + commands = await bot.rest.request('GET', f'/applications/{app_id}/commands') + + await client.post_bot_commands(commands) + +Discord.http +^^^^^^^^^^^^ + +.. code-block:: python + + http = discordhttp.HTTP(f'BOT {os.getenv("BOT_TOKEN")}') + app_id = ... + commands = await http.get(f'/applications/{app_id}/commands') + + await client.post_bot_commands(commands) + +Automatically posting your bot’s server count every few minutes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python - @client.autopost_retrieval + @client.bot_autopost_retrieval def get_server_count() -> int: return bot.server_count - @client.autopost_success + @client.bot_autopost_success def success(server_count: int) -> None: print(f'Successfully posted {server_count} servers to Top.gg!') - @client.autopost_error + @client.bot_autopost_error def error(error: topgg.Error) -> None: print(f'Error: {error!r}') - client.start_autoposter() + client.start_bot_autoposter() # ... - client.stop_autoposter() # Optional + client.stop_bot_autoposter() # Optional Checking if the weekend vote multiplier is active ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -183,8 +231,8 @@ Social Webhooks ~~~~~~~~ -Being notified whenever someone voted for your bot -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Being notified whenever someone voted for your project +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. code-block:: python @@ -193,11 +241,10 @@ Being notified whenever someone voted for your bot import asyncio import os - webhooks = topgg.Webhooks(os.getenv('MY_TOPGG_WEBHOOK_SECRET'), 8080) @webhooks.on_vote('/votes') - def voted(vote: topgg.Vote) -> None: + def voted(vote: topgg.VoteEvent) -> None: print(f'A user with the ID of {vote.voter_id} has voted us on Top.gg!') async def main() -> None: diff --git a/test.py b/test.py index 2897c95f..73bbb262 100644 --- a/test.py +++ b/test.py @@ -52,10 +52,13 @@ async def run() -> None: test_attributes(b) await asyncio.sleep(1) - await tg.post_server_count(2) + await tg.post_bot_commands([{'name': 'test', 'description': 'command description'}]) await asyncio.sleep(1) - posted_server_count = await tg.get_server_count() + await tg.post_bot_server_count(2) + + await asyncio.sleep(1) + posted_server_count = await tg.get_bot_server_count() assert posted_server_count == 2 @@ -66,9 +69,10 @@ async def run() -> None: test_attributes(voter) await asyncio.sleep(1) - has_voted = await tg.has_voted(661200758510977084) + await tg.get_vote(661200758510977084) - assert isinstance(has_voted, bool) + await asyncio.sleep(1) + await tg.get_vote(8226924471638491136, source=topgg.UserSource.TOPGG) await asyncio.sleep(1) is_weekend = await tg.is_weekend() diff --git a/test_autoposter.py b/test_autoposter.py index 520b44ea..05c59c7a 100644 --- a/test_autoposter.py +++ b/test_autoposter.py @@ -25,19 +25,19 @@ async def run() -> None: try: async with topgg.Client(os.getenv('TOPGG_TOKEN')) as tg: - @tg.autopost_retrieval + @tg.bot_autopost_retrieval def get_server_count() -> int: return 2 - @tg.autopost_success + @tg.bot_autopost_success def success(server_count: int) -> None: print(f'Successfully posted {server_count} servers to the API!') - @tg.autopost_error + @tg.bot_autopost_error def error(error: topgg.Error) -> None: print(f'Error: {error!r}') - tg.start_autoposter(5.0) + tg.start_bot_autoposter(5.0) await asyncio.sleep(15) finally: diff --git a/topgg/__init__.py b/topgg/__init__.py index d803f751..80d39f81 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -23,8 +23,8 @@ SOFTWARE. """ +from .models import Bot, SortBy, UserSource, Vote, VoteEvent, Voter from .errors import Error, RequestError, Ratelimited -from .models import Bot, SortBy, Vote, Voter from .widget import WidgetType from .webhooks import Webhooks from .version import VERSION @@ -47,8 +47,10 @@ 'Ratelimited', 'RequestError', 'SortBy', + 'UserSource', 'VERSION', 'Vote', + 'VoteEvent', 'Voter', 'Webhooks', 'widget', diff --git a/topgg/client.py b/topgg/client.py index a2eb31ce..8aac414d 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -31,29 +31,34 @@ from base64 import b64decode from time import time import binascii +import warnings import asyncio import json from .ratelimiter import Ratelimiter, Ratelimiters from .errors import Error, RequestError, Ratelimited -from .models import Bot, SortBy, Voter +from .models import Bot, SortBy, UserSource, Vote, Voter from .version import VERSION -BASE_URL = 'https://top.gg/api/v1' +BASE_URL = 'https://top.gg/api' MAXIMUM_DELAY_THRESHOLD = 5.0 -AutopostRetrievalCallback = Callable[[], Union[int, Coroutine[None, None, int]]] -AutopostRetrievalDecorator = Callable[ - [AutopostRetrievalCallback], AutopostRetrievalCallback +BotAutopostRetrievalCallback = Callable[[], Union[int, Coroutine[None, None, int]]] +BotAutopostRetrievalDecorator = Callable[ + [BotAutopostRetrievalCallback], BotAutopostRetrievalCallback ] -AutopostSuccessCallback = Callable[[int], Any] -AutopostSuccessDecorator = Callable[[AutopostSuccessCallback], AutopostSuccessCallback] +BotAutopostSuccessCallback = Callable[[int], Any] +BotAutopostSuccessDecorator = Callable[ + [BotAutopostSuccessCallback], BotAutopostSuccessCallback +] -AutopostErrorCallback = Callable[[Error], Any] -AutopostErrorDecorator = Callable[[AutopostErrorCallback], AutopostErrorCallback] +BotAutopostErrorCallback = Callable[[Error], Any] +BotAutopostErrorDecorator = Callable[ + [BotAutopostErrorCallback], BotAutopostErrorCallback +] class Client: @@ -75,7 +80,7 @@ class Client: async with topgg.Client(os.getenv('TOPGG_TOKEN')) as client: # ... - :param token: Your Top.gg API token. To retrieve it, see https://github.com/top-gg-community/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff. + :param token: Your Top.gg API token. :type token: :py:class:`str` :param session: Whether to use an existing :class:`~aiohttp.ClientSession` for requesting or not. Defaults to :py:obj:`None` (creates a new one instead) :type session: Optional[:class:`~aiohttp.ClientSession`] @@ -85,7 +90,10 @@ class Client: """ id: int - """This bot's ID.""" + """This project's ID.""" + + legacy: bool + """Whether this client is using a legacy API token.""" __slots__: tuple[str, ...] = ( '__own_session', @@ -94,11 +102,12 @@ class Client: '__ratelimiter', '__ratelimiters', '__current_ratelimit', - '__autopost_task', - '__autopost_retrieval_callback', - '__autopost_success_callbacks', - '__autopost_error_callbacks', + '__bot_autopost_task', + '__bot_autopost_retrieval_callback', + '__bot_autopost_success_callbacks', + '__bot_autopost_error_callbacks', 'id', + 'legacy', ) def __init__(self, token: str, *, session: Optional[ClientSession] = None): @@ -114,8 +123,10 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): try: encoded_json = token.split('.')[1] encoded_json += '=' * (4 - (len(encoded_json) % 4)) + encoded_json = json.loads(b64decode(encoded_json)) - self.id = int(json.loads(b64decode(encoded_json))['id']) + self.id = int(encoded_json['id']) + self.legacy = '_t' not in encoded_json except (IndexError, ValueError, binascii.Error, json.decoder.JSONDecodeError): raise ValueError('Got a malformed API token.') from None @@ -127,10 +138,10 @@ def __init__(self, token: str, *, session: Optional[ClientSession] = None): self.__ratelimiters = Ratelimiters(self.__ratelimiter) self.__current_ratelimit = None - self.__autopost_task = None - self.__autopost_retrieval_callback = None - self.__autopost_success_callbacks = set() - self.__autopost_error_callbacks = set() + self.__bot_autopost_task = None + self.__bot_autopost_retrieval_callback = None + self.__bot_autopost_success_callbacks = set() + self.__bot_autopost_error_callbacks = set() def __repr__(self) -> str: return f'<{__class__.__name__} {self.__session!r}>' @@ -178,7 +189,7 @@ async def __request( method, BASE_URL + path, headers={ - 'Authorization': self.__token, + 'Authorization': f'Bearer {self.__token}', 'Content-Type': 'application/json', 'User-Agent': f'topggpy (https://github.com/top-gg-community/python-sdk {VERSION}) Python/', }, @@ -187,7 +198,7 @@ async def __request( status = resp.status retry_after = float(resp.headers.get('Retry-After', 0)) - if 'application/json' in resp.headers['Content-Type']: + if 'json' in resp.headers['Content-Type']: try: output = await resp.json() except json.decoder.JSONDecodeError: @@ -207,7 +218,9 @@ async def __request( return await self.__request(method, path) - raise RequestError(output and output.get('message'), status) from None + raise RequestError( + output and output.get('message', output.get('detail')), status + ) from None async def get_bot(self, id: int) -> Bot: """ @@ -290,7 +303,7 @@ async def get_bots( return map(Bot, bots.get('results', ())) - async def get_server_count(self) -> Optional[int]: + async def get_bot_server_count(self) -> Optional[int]: """ Fetches your Discord bot's posted server count. @@ -298,7 +311,7 @@ async def get_server_count(self) -> Optional[int]: .. code-block:: python - posted_server_count = await client.get_server_count() + posted_server_count = await client.get_bot_server_count() :exception Error: The client is already closed. :exception RequestError: Received a non-favorable response from the API. @@ -312,7 +325,7 @@ async def get_server_count(self) -> Optional[int]: return stats.get('server_count') - async def post_server_count(self, new_server_count: int) -> None: + async def post_bot_server_count(self, new_server_count: int) -> None: """ Updates the server count in your Discord bot's Top.gg page. @@ -320,12 +333,12 @@ async def post_server_count(self, new_server_count: int) -> None: .. code-block:: python - await client.post_server_count(bot.server_count) + await client.post_bot_server_count(bot.server_count) :param new_server_count: The updated server count. This cannot be zero. :type new_server_count: :py:class:`int` - :exception ValueError: The new_server_count argument is zero or lower. + :exception ValueError: ``new_server_count`` is zero or lower. :exception Error: The client is already closed. :exception RequestError: Received a non-favorable response from the API. :exception Ratelimited: Ratelimited from sending more requests. @@ -362,7 +375,7 @@ async def is_weekend(self) -> bool: async def get_voters(self, page: int = 1) -> Iterable[Voter]: """ - Fetches your Discord bot's recent unique voters. + Fetches your project's recent 100 unique voters. Examples: @@ -397,7 +410,10 @@ async def get_voters(self, page: int = 1) -> Iterable[Voter]: async def has_voted(self, id: int) -> bool: """ - Checks if a Discord user has voted for your Discord bot in the past 12 hours. + .. deprecated:: 3.0.0 + Legacy API. Use a v1 API token with :meth:`.Client.get_vote` instead. + + Checks if a Top.gg user has voted for your project in the past 12 hours. Example: @@ -416,24 +432,124 @@ async def has_voted(self, id: int) -> bool: :rtype: :py:class:`bool` """ + warnings.warn( + '`has_voted()` is deprecated. Use a v1 API token with `get_vote()` instead.', + DeprecationWarning, + ) + response = await self.__request('GET', '/bots/check', params={'userId': id}) return bool(response['voted']) - async def __autopost_loop(self, interval: Optional[float]) -> None: + async def get_vote( + self, id: int, source: UserSource = UserSource.DISCORD + ) -> Optional[Vote]: + """ + Fetches the latest vote information of a Top.gg user on your project. + + Example: + + .. code-block:: python + + # Discord ID + vote = await client.get_vote(661200758510977084) + + if vote: + print(f'User has voted: {vote!r}') + + # Top.gg ID + vote = await client.get_vote(8226924471638491136, source=topgg.UserSource.TOPGG) + + if vote: + print(f'User has voted: {vote!r}') + + :param id: The user's ID. + :type id: :py:class:`int` + :param source: The ID type to use. Defaults to :attr:`.UserSource.DISCORD`. + :type source: :class:`.UserSource` + + :exception Error: A legacy API token is used or the client is already closed. + :exception TypeError: ``source`` is not an instance of :class:`.UserSource`. + :exception RequestError: The specified user has not logged in to Top.gg or the client has received other non-favorable responses from the API. + :exception Ratelimited: Ratelimited from sending more requests. + + :returns: The user's latest vote information on your project or :py:obj:`None` if the user has not voted for your project in the past 12 hours. + :rtype: Optional[:class:`.Vote`] + """ + + if self.legacy: + raise Error('This endpoint is inaccessible with legacy API tokens.') + elif not isinstance(source, UserSource): + raise TypeError('Expected source to be an instance of UserSource.') + + try: + response = await self.__request( + 'GET', f'/v1/projects/@me/votes/{id}', params={'source': source.value} + ) + + return Vote(response, self.id, id) + except RequestError as err: + if err.message == 'User has not voted in the last 12 hours.': + return + + raise + + async def post_bot_commands(self, commands: list[dict]) -> None: + """ + Updates the application commands list in your Discord bot's Top.gg page. + + Examples: + + .. code-block:: python + + # Discord.py/Pycord/Nextcord/Disnake: + app_id = bot.user.id + commands = await bot.http.get_global_commands(app_id) + + # Hikari: + app_id = ... + commands = await bot.rest.request('GET', f'/applications/{app_id}/commands') + + # Discord.http: + http = discordhttp.HTTP(f'BOT {os.getenv("BOT_TOKEN")}') + app_id = ... + commands = await http.get(f'/applications/{app_id}/commands') + + await client.post_bot_commands(commands) + + :param commands: A list of application commands in raw Discord API JSON dicts. This cannot be empty. + :type commands: list[:py:class:`dict`] + + :exception Error: A legacy API token is used or the client is already closed. + :exception TypeError: ``commands`` is not a list of raw Discord API JSON dicts. + :exception RequestError: Received a non-favorable response from the API. + :exception Ratelimited: Ratelimited from sending more requests. + """ + + if self.legacy: + raise Error('This endpoint is inaccessible with legacy API tokens.') + elif not ( + isinstance(commands, list) + and all(isinstance(command, dict) for command in commands) + ): + raise TypeError('Expected commands to be a list of raw Discord API JSON dicts.') + + await self.__request('POST', '/v1/projects/@me/commands', body=commands) + + async def __bot_autopost_loop(self, interval: Optional[float]) -> None: # The following line should not be changed, as it could affect test_autoposter.py. interval = max(interval or 900.0, 900.0) while True: try: - server_count = self.__autopost_retrieval_callback() + server_count = self.__bot_autopost_retrieval_callback() if isawaitable(server_count): server_count = await server_count - await self.post_server_count(server_count) + await self.post_bot_server_count(server_count) - for success_callback in self.__autopost_success_callbacks: + for success_callback in self.__bot_autopost_success_callbacks: success_callback_result = success_callback(server_count) if isawaitable(success_callback_result): @@ -442,7 +558,7 @@ async def __autopost_loop(self, interval: Optional[float]) -> None: await asyncio.sleep(interval) except Exception as err: if isinstance(err, Error): - for error_callback in self.__autopost_error_callbacks: + for error_callback in self.__bot_autopost_error_callbacks: error_callback_result = error_callback(err) if isawaitable(error_callback_result): @@ -452,29 +568,31 @@ async def __autopost_loop(self, interval: Optional[float]) -> None: else: raise - def autopost_retrieval( - self, callback: Optional[AutopostRetrievalCallback] = None - ) -> Union[AutopostRetrievalCallback, AutopostRetrievalDecorator]: + def bot_autopost_retrieval( + self, callback: Optional[BotAutopostRetrievalCallback] = None + ) -> Union[BotAutopostRetrievalCallback, BotAutopostRetrievalDecorator]: """ - Registers an autopost server count retrieval callback. + Registers a bot autopost server count retrieval callback. Example: .. code-block:: python - @client.autopost_retrieval + @client.bot_autopost_retrieval def get_server_count() -> int: return bot.server_count :param callback: The autopost server count retrieval callback. This can be asynchronous or synchronous, as long as it eventually returns an :py:class:`int`. - :type callback: Optional[:data:`~.client.AutopostRetrievalCallback`] + :type callback: Optional[:data:`~.client.BotAutopostRetrievalCallback`] :returns: The function itself or a decorated function depending on the argument. - :rtype: Union[:data:`~.client.AutopostRetrievalCallback`, :data:`~.client.AutopostRetrievalDecorator`] + :rtype: Union[:data:`~.client.BotAutopostRetrievalCallback`, :data:`~.client.BotAutopostRetrievalDecorator`] """ - def decorator(callback: AutopostRetrievalCallback) -> AutopostRetrievalCallback: - self.__autopost_retrieval_callback = callback + def decorator( + callback: BotAutopostRetrievalCallback, + ) -> BotAutopostRetrievalCallback: + self.__bot_autopost_retrieval_callback = callback return callback @@ -485,29 +603,29 @@ def decorator(callback: AutopostRetrievalCallback) -> AutopostRetrievalCallback: return decorator - def autopost_success( - self, callback: Optional[AutopostSuccessCallback] = None - ) -> Union[AutopostSuccessCallback, AutopostSuccessDecorator]: + def bot_autopost_success( + self, callback: Optional[BotAutopostSuccessCallback] = None + ) -> Union[BotAutopostSuccessCallback, BotAutopostSuccessDecorator]: """ - Registers an autopost on success callback. Several callbacks are possible. + Registers a bot autopost on success callback. Several callbacks are possible. Example: .. code-block:: python - @client.autopost_success + @client.bot_autopost_success def success(server_count: int) -> None: print(f'Successfully posted {server_count} servers to Top.gg!') :param callback: The autopost on success callback. This can be asynchronous or synchronous, as long as it accepts a :py:class:`int` argument for the posted server count. - :type callback: Optional[:data:`~.client.AutopostSuccessCallback`] + :type callback: Optional[:data:`~.client.BotAutopostSuccessCallback`] :returns: The function itself or a decorated function depending on the argument. - :rtype: Union[:data:`~.client.AutopostSuccessCallback`, :data:`~.client.AutopostSuccessDecorator`] + :rtype: Union[:data:`~.client.BotAutopostSuccessCallback`, :data:`~.client.BotAutopostSuccessDecorator`] """ - def decorator(callback: AutopostSuccessCallback) -> AutopostSuccessCallback: - self.__autopost_success_callbacks.add(callback) + def decorator(callback: BotAutopostSuccessCallback) -> BotAutopostSuccessCallback: + self.__bot_autopost_success_callbacks.add(callback) return callback @@ -518,29 +636,29 @@ def decorator(callback: AutopostSuccessCallback) -> AutopostSuccessCallback: return decorator - def autopost_error( - self, callback: Optional[AutopostErrorCallback] = None - ) -> Union[AutopostErrorCallback, AutopostErrorDecorator]: + def bot_autopost_error( + self, callback: Optional[BotAutopostErrorCallback] = None + ) -> Union[BotAutopostErrorCallback, BotAutopostErrorDecorator]: """ - Registers an autopost on error handler. Several callbacks are possible. + Registers a bot autopost on error handler. Several callbacks are possible. Example: .. code-block:: python - @client.autopost_error + @client.bot_autopost_error def error(error: topgg.Error) -> None: print(f'Error: {error!r}') :param callback: The autopost on error handler. This can be asynchronous or synchronous, as long as it accepts an :class:`.Error` argument for the request exception. - :type callback: Optional[:data:`~.client.AutopostErrorCallback`] + :type callback: Optional[:data:`~.client.BotAutopostErrorCallback`] :returns: The function itself or a decorated function depending on the argument. - :rtype: Union[:data:`~.client.AutopostErrorCallback`, :data:`~.client.AutopostErrorDecorator`] + :rtype: Union[:data:`~.client.BotAutopostErrorCallback`, :data:`~.client.BotAutopostErrorDecorator`] """ - def decorator(callback: AutopostErrorCallback) -> AutopostErrorCallback: - self.__autopost_error_callbacks.add(callback) + def decorator(callback: BotAutopostErrorCallback) -> BotAutopostErrorCallback: + self.__bot_autopost_error_callbacks.add(callback) return callback @@ -552,20 +670,20 @@ def decorator(callback: AutopostErrorCallback) -> AutopostErrorCallback: return decorator @property - def autoposter_running(self) -> bool: - """Whether the autoposter is running.""" + def bot_autoposter_running(self) -> bool: + """Whether the Discord bot autoposter is running.""" - return self.__autopost_task is not None + return self.__bot_autopost_task is not None - def start_autoposter(self, interval: Optional[float] = None) -> None: + def start_bot_autoposter(self, interval: Optional[float] = None) -> None: """ - Starts the autoposter, which automatically updates the server count in your Discord bot's Top.gg page every few minutes. + Starts the Discord bot autoposter, which automatically updates the server count in your Discord bot's Top.gg page every few minutes. Example: .. code-block:: python - client.start_autoposter() + client.start_bot_autoposter() :param interval: The delay between updates in seconds. :type interval: Optional[:py:class:`float`] @@ -573,27 +691,27 @@ def start_autoposter(self, interval: Optional[float] = None) -> None: :exception TypeError: The server count retrieval callback does not exist. """ - if not self.autoposter_running: + if not self.bot_autoposter_running: assert ( - self.__autopost_retrieval_callback is not None - ), 'Missing autopost_retrieval callback.' + self.__bot_autopost_retrieval_callback is not None + ), 'Missing bot_autopost_retrieval callback.' - self.__autopost_task = asyncio.create_task(self.__autopost_loop(interval)) + self.__bot_autopost_task = asyncio.create_task(self.__bot_autopost_loop(interval)) - def stop_autoposter(self) -> None: + def stop_bot_autoposter(self) -> None: """ - Stops the autoposter. + Stops the Discord bot autoposter. Example: .. code-block:: python - client.stop_autoposter() + client.stop_bot_autoposter() """ - if self.autoposter_running: - self.__autopost_task.cancel() - self.__autopost_task = None + if self.bot_autoposter_running: + self.__bot_autopost_task.cancel() + self.__bot_autopost_task = None async def close(self) -> None: """ @@ -606,7 +724,7 @@ async def close(self) -> None: await client.close() """ - self.stop_autoposter() + self.stop_bot_autoposter() if self.__own_session and not self.__session.closed: await self.__session.close() diff --git a/topgg/models.py b/topgg/models.py index 96f94173..7fc4f638 100644 --- a/topgg/models.py +++ b/topgg/models.py @@ -23,8 +23,8 @@ SOFTWARE. """ +from datetime import datetime, timezone from typing import Optional, TypeVar -from datetime import datetime from urllib import parse from enum import Enum @@ -38,15 +38,55 @@ def truthy_only(value: Optional[T]) -> Optional[T]: class Vote: + """A Top.gg vote.""" + + __slots__ = ('receiver_id', 'voter_id', 'voted_at', 'expires_at', 'weight') + + receiver_id: int + """The ID of the project that received a vote.""" + + voter_id: int + """The ID of the Top.gg user who voted.""" + + weight: int + """This vote's weight.""" + + voted_at: datetime + """When the vote was cast.""" + + expires_at: datetime + """When the vote expires and the user is required to vote again.""" + + def __init__(self, json: dict, receiver_id: int, voter_id: int): + self.receiver_id = receiver_id + self.voter_id = voter_id + self.weight = json['weight'] + self.voted_at = datetime.fromisoformat(json['created_at']) + self.expires_at = datetime.fromisoformat(json['expires_at']) + + def __repr__(self) -> str: + return f'<{__class__.__name__} receiver_id={self.receiver_id} voter_id={self.voter_id} weight={self.weight} voted_at={self.voted_at!r} expires_at={self.expires_at!r}>' + + def __bool__(self) -> bool: + return self.expired + + @property + def expired(self) -> bool: + """Whether this vote is now expired.""" + + return datetime.now(tz=timezone.utc) >= self.expires_at + + +class VoteEvent: """A dispatched Top.gg vote event.""" __slots__ = ('receiver_id', 'voter_id', 'is_test', 'is_weekend', 'query') receiver_id: int - """The ID of the Discord bot/server that received a vote.""" + """The ID of the project that received a vote.""" voter_id: int - """The ID of the Discord user who voted.""" + """The ID of the Top.gg user who voted.""" is_test: bool """Whether this vote is just a test done from the page settings.""" @@ -84,7 +124,7 @@ class Voter: __slots__: tuple[str, ...] = ('id', 'username', 'avatar') id: int - """This voter's Discord ID.""" + """This voter's ID.""" username: str """This voter's username.""" @@ -244,3 +284,10 @@ class SortBy(Enum): MONTHLY_VOTES = 'monthlyPoints' """Sorts results based on each bot's monthly vote count.""" + + +class UserSource(Enum): + """A user account from an external platform that is linked to a Top.gg user account.""" + + DISCORD = 'discord' + TOPGG = 'topgg' diff --git a/topgg/webhooks.py b/topgg/webhooks.py index 2dad4ac3..ba8df407 100644 --- a/topgg/webhooks.py +++ b/topgg/webhooks.py @@ -29,11 +29,11 @@ from json import JSONDecodeError from inspect import isawaitable -from .models import Vote +from .models import VoteEvent RawCallback = Callable[[web.Request], Awaitable[web.StreamResponse]] -OnVoteCallback = Callable[[Vote], Any] +OnVoteCallback = Callable[[VoteEvent], Any] OnVoteDecorator = Callable[[OnVoteCallback], RawCallback] @@ -72,14 +72,14 @@ def on_vote( callback: Optional[OnVoteCallback] = None, ) -> Union[OnVoteCallback, OnVoteDecorator]: """ - Registers a route that gets called whenever your Discord bot/server receives a vote. + Registers a route that gets called whenever your project receives a vote. Example: .. code-block:: python @webhooks.on_vote('/votes') - def voted(vote: topgg.Vote) -> None: + def voted(vote: topgg.VoteEvent) -> None: print(f'A user with the ID of {vote.voter_id} has voted us on Top.gg!') :param route: The route to use. @@ -108,7 +108,7 @@ async def handler(request: web.Request) -> web.Response: return web.Response(status=401, text='Unauthorized') try: - vote = Vote(await request.json()) + vote = VoteEvent(await request.json()) except (JSONDecodeError, ContentTypeError): return web.Response(status=400, text='Bad request') diff --git a/topgg/widget.py b/topgg/widget.py index 3e439ad9..36033b02 100644 --- a/topgg/widget.py +++ b/topgg/widget.py @@ -31,7 +31,7 @@ def large(type: WidgetType, id: int) -> str: :rtype: :py:class:`str` """ - return f'{BASE_URL}/widgets/large/{type.value}/{id}' + return f'{BASE_URL}/v1/widgets/large/{type.value}/{id}' def votes(type: WidgetType, id: int) -> str: @@ -53,12 +53,12 @@ def votes(type: WidgetType, id: int) -> str: :rtype: :py:class:`str` """ - return f'{BASE_URL}/widgets/small/votes/{type.value}/{id}' + return f'{BASE_URL}/v1/widgets/small/votes/{type.value}/{id}' def owner(type: WidgetType, id: int) -> str: """ - Generates a small widget URL for displaying an entity's owner. + Generates a small widget URL for displaying a project's owner. Example: @@ -75,7 +75,7 @@ def owner(type: WidgetType, id: int) -> str: :rtype: :py:class:`str` """ - return f'{BASE_URL}/widgets/small/owner/{type.value}/{id}' + return f'{BASE_URL}/v1/widgets/small/owner/{type.value}/{id}' def social(type: WidgetType, id: int) -> str: @@ -97,4 +97,4 @@ def social(type: WidgetType, id: int) -> str: :rtype: :py:class:`str` """ - return f'{BASE_URL}/widgets/small/social/{type.value}/{id}' + return f'{BASE_URL}/v1/widgets/small/social/{type.value}/{id}'