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
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
deleted file mode 100644
index 60b37a8f..00000000
--- a/.github/workflows/python-package.yml
+++ /dev/null
@@ -1,41 +0,0 @@
-# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
-# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
-
-name: Test Python package
-
-on:
- push:
- branches: [ master ]
- pull_request:
- branches: [ master ]
-
-jobs:
- build:
-
- runs-on: ubuntu-latest
- strategy:
- matrix:
- python-version: [ 3.6, 3.7, 3.8, 3.9 ]
-
- steps:
- - uses: actions/checkout@v2
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v2
- with:
- python-version: ${{ matrix.python-version }}
- - name: Install testing dependencies
- uses: py-actions/py-dependency-install@v2
- with:
- path: "requirements-dev.txt"
- - name: Install itself
- run: |
- python setup.py install
- - name: Lint with flake8
- run: |
- # stop the build if there are Python syntax errors or undefined names
- flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
- flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics
- - name: Test with pytest
- run: |
- pytest
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
deleted file mode 100644
index 1eba4d89..00000000
--- a/.github/workflows/python-publish.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-# This workflow will upload a Python Package using Twine when a release is created
-# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
-
-name: Upload Python Package
-
-on:
- release:
- types: [ created ]
-
-jobs:
- deploy:
-
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v2
- - name: Set up Python
- uses: actions/setup-python@v2
- with:
- python-version: '3.7'
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install setuptools wheel twine -r requirements.txt
- - name: Build and publish
- env:
- TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
- TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
- run: |
- python setup.py sdist bdist_wheel
- twine upload dist/*
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..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/.gitignore b/.gitignore
index 3d7edd2f..f222ed9d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,6 @@
-dblpy.egg-info/
+**/__pycache__/
topggpy.egg-info/
-topgg/__pycache__/
-build/
+docs/_build/
dist/
-/docs/_build
-/docs/_templates
-.vscode
-/.idea/
-__pycache__
-.coverage
+.ruff_cache/
+.vscode/
\ No newline at end of file
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/.readthedocs.yml b/.readthedocs.yml
deleted file mode 100644
index 388f9a1f..00000000
--- a/.readthedocs.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-version: 2
-
-sphinx:
- configuration: docs/conf.py
-
-build:
- image: latest
-
-python:
- version: 3.8
- install:
- - requirements: requirements.txt
- - requirements: requirements-docs.txt
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 96aaaf80..3a68f837 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,7 +1,22 @@
-Copyright 2021 Assanali Mukhanov & Top.gg
+The MIT License (MIT)
-Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+Copyright (c) 2021 Assanali Mukhanov & Top.gg
+Copyright (c) 2024-2025 null8626 & Top.gg
-The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/MANIFEST.in b/MANIFEST.in
index dc068afa..d68037a4 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,3 +1,10 @@
-include LICENSE
-include requirements.txt
-include README.rst
+prune .github
+prune .ruff_cache
+prune docs
+exclude .gitattributes
+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/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..a9f1c418
--- /dev/null
+++ b/README.md
@@ -0,0 +1,242 @@
+# Top.gg Python SDK
+
+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 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 project](#being-notified-whenever-someone-voted-for-your-project)
+
+## Installation
+
+```sh
+$ pip install topggpy
+```
+
+## Setting up
+
+### Implicit cleanup
+
+```py
+import topgg
+
+import os
+
+async with topgg.Client(os.getenv('TOPGG_TOKEN')) as client:
+ # ...
+```
+
+### Explicit cleanup
+
+```py
+import topgg
+
+import os
+
+client = topgg.Client(os.getenv('TOPGG_TOKEN'))
+
+# ...
+
+await client.close()
+```
+
+## Usage
+
+### Getting a bot
+
+```py
+bot = await client.get_bot(432610292342587392)
+```
+
+### Getting several bots
+
+#### With defaults
+
+```py
+bots = await client.get_bots()
+
+for bot in bots:
+ print(bot)
+```
+
+#### With explicit arguments
+
+```py
+bots = await client.get_bots(limit=250, offset=50, sort_by=topgg.SortBy.MONTHLY_VOTES)
+
+for bot in bots:
+ print(bot)
+```
+
+### Getting your project's voters
+
+#### First page
+
+```py
+voters = await client.get_voters()
+
+for voter in voters:
+ print(voter)
+```
+
+#### Subsequent pages
+
+```py
+voters = await client.get_voters(2)
+
+for voter in voters:
+ print(voter)
+```
+
+### Getting your project's vote information of a user
+
+#### Discord ID
+
+```py
+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_bot_server_count()
+```
+
+### Posting your bot's server count
+
+```py
+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.bot_autopost_retrieval
+def get_server_count() -> int:
+ return bot.server_count
+
+@client.bot_autopost_success
+def success(server_count: int) -> None:
+ print(f'Successfully posted {server_count} servers to Top.gg!')
+
+@client.bot_autopost_error
+def error(error: topgg.Error) -> None:
+ print(f'Error: {error!r}')
+
+client.start_bot_autoposter()
+
+# ...
+
+client.stop_bot_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 project
+
+```py
+import topgg
+
+import asyncio
+import os
+
+webhooks = topgg.Webhooks(os.getenv('MY_TOPGG_WEBHOOK_SECRET'), 8080)
+
+@webhooks.on_vote('/votes')
+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:
+ await webhooks.start() # Starts the server
+ await asyncio.Event().wait() # Keeps the server alive through indefinite blocking
+
+if __name__ == '__main__':
+ asyncio.run(main())
+```
\ No newline at end of file
diff --git a/README.rst b/README.rst
deleted file mode 100644
index 5bdddce6..00000000
--- a/README.rst
+++ /dev/null
@@ -1,60 +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/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, supporting discord.py.
-
-Installation
-------------
-
-Install via pip (recommended)
-
-.. code:: bash
-
- pip3 install topggpy
-
-Install from source
-
-.. code:: bash
-
- pip3 install git+https://github.com/top-gg/python-sdk/
-
-Documentation
--------------
-
-Documentation can be found `here `_
-
-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
-* 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/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/img/favicon-16x16.png b/docs/_static/img/favicon-16x16.png
deleted file mode 100644
index 58c60e92..00000000
Binary files a/docs/_static/img/favicon-16x16.png and /dev/null differ
diff --git a/docs/_static/script.js b/docs/_static/script.js
new file mode 100644
index 00000000..b576dcd5
--- /dev/null
+++ b/docs/_static/script.js
@@ -0,0 +1,44 @@
+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 tocDrawer = document.querySelector('aside.toc-drawer')
+
+if (document.querySelector('section#topggpy')) {
+ // 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')
+
+ link.addEventListener('click', event => {
+ event.preventDefault()
+ label.click()
+ })
+}
\ No newline at end of file
diff --git a/docs/_static/style.css b/docs/_static/style.css
new file mode 100644
index 00000000..944637a6
--- /dev/null
+++ b/docs/_static/style.css
@@ -0,0 +1,62 @@
+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 *, em {
+ font-style: normal !important;
+ text-decoration: none !important;
+ font-weight: normal !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 {
+ font-weight: 900;
+}
+
+.sidebar-brand-text {
+ font-weight: bolder;
+}
\ No newline at end of file
diff --git a/docs/api.rst b/docs/api.rst
deleted file mode 100644
index 69691659..00000000
--- a/docs/api.rst
+++ /dev/null
@@ -1,19 +0,0 @@
-.. currentmodule:: topgg
-
-#############
-API Reference
-#############
-
-The following section outlines the API of topggpy.
-
-Index:
-
- .. 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/autopost.rst b/docs/api/autopost.rst
deleted file mode 100644
index 668af79a..00000000
--- a/docs/api/autopost.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-#######################
-Auto-post API Reference
-#######################
-
-.. automodule:: topgg.autopost
- :members:
- :inherited-members:
diff --git a/docs/api/client.rst b/docs/api/client.rst
deleted file mode 100644
index 1bac1971..00000000
--- a/docs/api/client.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-####################
-Client API 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 3f10ff2e..00000000
--- a/docs/api/data.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-##################
-Data API 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 804fdfa3..00000000
--- a/docs/api/errors.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-####################
-Errors API Reference
-####################
-
-.. automodule:: topgg.errors
- :members:
- :inherited-members:
\ No newline at end of file
diff --git a/docs/api/types.rst b/docs/api/types.rst
deleted file mode 100644
index a6a70f84..00000000
--- a/docs/api/types.rst
+++ /dev/null
@@ -1,42 +0,0 @@
-####################
-Models API 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 53a41c92..00000000
--- a/docs/api/webhook.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-#####################
-Webhook API 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..12e5b345
--- /dev/null
+++ b/docs/client.rst
@@ -0,0 +1,35 @@
+Client reference
+================
+
+.. autoclass:: topgg.client.Client
+ :members:
+
+.. autoclass:: topgg.models.UserSource()
+ :members:
+ :undoc-members:
+
+.. autoclass:: topgg.widget.WidgetType()
+ :members:
+ :undoc-members:
+
+.. autofunction:: topgg.widget.large
+.. autofunction:: topgg.widget.owner
+.. autofunction:: topgg.widget.social
+.. autofunction:: topgg.widget.votes
+
+.. autoclass:: topgg.errors.Error()
+
+.. autoclass:: topgg.errors.RequestError()
+ :members:
+
+.. autoclass:: topgg.errors.Ratelimited()
+ :members:
+
+.. autodata:: topgg.client.BotAutopostRetrievalCallback
+.. autodata:: topgg.client.BotAutopostRetrievalDecorator
+
+.. autodata:: topgg.client.BotAutopostSuccessCallback
+.. autodata:: topgg.client.BotAutopostSuccessDecorator
+
+.. autodata:: topgg.client.BotAutopostErrorCallback
+.. autodata:: topgg.client.BotAutopostErrorDecorator
\ No newline at end of file
diff --git a/docs/conf.py b/docs/conf.py
index 2d368576..7fc7eee2 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,233 +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
-import alabaster
-
-sys.path.insert(0, os.path.abspath("../"))
-from topgg import __version__ as version
-
-# import re
+sys.path.insert(0, os.path.join(os.getcwd(), '..', 'topgg'))
+sys.path.insert(0, os.path.abspath('..'))
-# -- General configuration ------------------------------------------------
+from version import VERSION
-# 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",
- "sphinx.ext.autosectionlabel",
- "sphinx.ext.extlinks",
- "sphinx.ext.intersphinx",
- "sphinx.ext.napoleon",
-]
+project = 'topggpy'
+author = 'null8626'
-autodoc_member_order = "groupwise"
+copyright = ''
+with open('../LICENSE', 'r') as f:
+ copyright = re.search(r'[\d\-]+ null8626', f.read()).group()
-extlinks = {
- "issue": ("https://github.com/top-gg/python-sdk/issues/%s", "GH-"),
-}
+version = VERSION
+extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx_reredirects']
intersphinx_mapping = {
- "py": ("https://docs.python.org/3", None),
- "discord": ("https://discordpy.readthedocs.io/en/latest/", None),
- "aiohttp": ("https://docs.aiohttp.org/en/stable/", None),
+ 'py': ('https://docs.python.org/3', None),
+ 'aio': ('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"]
-
-# The suffix(es) of source filenames.
-# You can specify multiple suffix as a list of string:
-#
-# source_suffix = ['.rst', '.md']
-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.
-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
-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_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',
+redirects = {
+ 'support-server': 'https://discord.gg/dbl',
+ 'repository': 'https://github.com/top-gg-community/python-sdk',
+ 'raw-api-reference': 'https://docs.top.gg/docs/',
}
-# 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,
- "topggpy",
- "topggpy Documentation",
- author,
- "topggpy",
- "One line description of project.",
- "Miscellaneous",
- ),
+html_css_files = [
+ '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..18f8483b
--- /dev/null
+++ b/docs/data.rst
@@ -0,0 +1,14 @@
+Data reference
+==============
+
+.. autoclass:: topgg.models.Bot()
+ :members:
+
+.. 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 a634d380..b9aba7f3 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,21 +1,266 @@
-.. topggpy documentation master file, created by
- sphinx-quickstart on Thu Feb 8 18:32:44 2018.
- You can adapt this file completely to your liking, but it should at least
- contain the root `toctree` directive.
+Top.gg Python SDK
+=================
-###################################
-Welcome to topggpy's documentation!
-###################################
+The community-maintained Python library for Top.gg.
-.. toctree::
- :maxdepth: 1
+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 project’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)
+
+Getting your project’s vote information of a user
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Discord ID
+^^^^^^^^^^
+
+.. code-block:: python
+
+ vote = await client.get_vote(661200758510977084)
+
+ if vote:
+ print(f'User has voted: {vote!r}')
+
+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_bot_server_count()
- api
- whats_new
+Posting your bot’s server count
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-Indices and tables
-==================
+.. code-block:: python
+
+ await client.post_bot_server_count(bot.server_count)
+
+Posting your bot’s application commands list
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Discord.py/Pycord/Nextcord/Disnake
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: python
+
+ 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.bot_autopost_retrieval
+ def get_server_count() -> int:
+ return bot.server_count
+
+ @client.bot_autopost_success
+ def success(server_count: int) -> None:
+ print(f'Successfully posted {server_count} servers to Top.gg!')
+
+ @client.bot_autopost_error
+ def error(error: topgg.Error) -> None:
+ print(f'Error: {error!r}')
+
+ client.start_bot_autoposter()
+
+ # ...
+
+ client.stop_bot_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 project
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. 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.VoteEvent) -> 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
+ :hidden:
-* :ref:`genindex`
-* :ref:`modindex`
-* :ref:`search`
+ client
+ data
+ webhooks
+ 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/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
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/requirements.txt b/docs/requirements.txt
new file mode 100644
index 00000000..63a65e99
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,4 @@
+aiohttp
+furo
+pyyaml
+sphinx-reredirects
\ No newline at end of file
diff --git a/docs/support-server.rst b/docs/support-server.rst
new file mode 100644
index 00000000..531270f0
--- /dev/null
+++ b/docs/support-server.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
deleted file mode 100644
index 9afe2351..00000000
--- a/docs/topgg.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
diff --git a/docs/webhooks.rst b/docs/webhooks.rst
new file mode 100644
index 00000000..ed5ce54e
--- /dev/null
+++ b/docs/webhooks.rst
@@ -0,0 +1,8 @@
+Webhooks reference
+==================
+
+.. autoclass:: topgg.webhooks.Webhooks
+ :members:
+
+.. autodata:: topgg.webhooks.OnVoteCallback
+.. autodata:: topgg.webhooks.OnVoteDecorator
\ No newline at end of file
diff --git a/docs/whats_new.rst b/docs/whats_new.rst
deleted file mode 100644
index 8fc1d0e9..00000000
--- a/docs/whats_new.rst
+++ /dev/null
@@ -1,168 +0,0 @@
-.. currentmodule:: topgg
-
-.. _whats_new:
-
-##########
-What's New
-##########
-
-This page keeps a detailed human friendly rendering of what's new and changed in specific versions.
-
-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 ``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 f1c1f6dd..00000000
--- a/examples/discordpy_example/__main__.py
+++ /dev/null
@@ -1,58 +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
-
-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
- 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
- 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 e6592a6d..00000000
--- a/examples/discordpy_example/callbacks/autopost.py
+++ /dev/null
@@ -1,56 +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 358753c1..00000000
--- a/examples/discordpy_example/callbacks/webhook.py
+++ /dev/null
@@ -1,39 +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 0bef502f..00000000
--- a/examples/hikari_example/__main__.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 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
- dblclient.default_bot_id = me.id
-
- # 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 3ac467b3..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()), shard_count=app.shard_count
- )
diff --git a/examples/hikari_example/callbacks/webhook.py b/examples/hikari_example/callbacks/webhook.py
deleted file mode 100644
index 50c53a73..00000000
--- a/examples/hikari_example/callbacks/webhook.py
+++ /dev/null
@@ -1,44 +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 ddc7aa22..00000000
--- a/examples/hikari_example/events/autopost.py
+++ /dev/null
@@ -1,34 +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 b9b6d21f..00000000
--- a/examples/hikari_example/events/webhook.py
+++ /dev/null
@@ -1,31 +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
new file mode 100644
index 00000000..a82f67be
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,36 @@
+[build-system]
+requires = ["setuptools"]
+
+[project]
+name = "topggpy"
+version = "3.0.0"
+description = "A simple API wrapper for Top.gg written in Python."
+readme = "README.md"
+license = { text = "MIT" }
+authors = [{ name = "null8626" }, { name = "Top.gg" }]
+keywords = ["discord", "discord-bot", "topgg"]
+dependencies = ["aiohttp>=3.12.15"]
+classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "License :: OSI Approved :: MIT License",
+ "Intended Audience :: Developers",
+ "Natural Language :: English",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Topic :: Internet",
+ "Topic :: Software Development :: Libraries",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+ "Topic :: Utilities"
+]
+requires-python = ">=3.9"
+
+[project.urls]
+Documentation = "https://topggpy.readthedocs.io/en/latest/"
+"Raw API Documentation" = "https://docs.top.gg/docs/"
+Repository = "https://github.com/top-gg-community/python-sdk"
+"Support server" = "https://discord.gg/dbl"
\ No newline at end of file
diff --git a/pytest.ini b/pytest.ini
deleted file mode 100644
index 919cbc45..00000000
--- a/pytest.ini
+++ /dev/null
@@ -1,8 +0,0 @@
-[pytest]
-xfail_strict = true
-norecursedirs = docs *.egg-info .git
-
-filterwarnings =
- ignore::DeprecationWarning
-
-addopts = --cov=topgg
\ No newline at end of file
diff --git a/requirements-dev.txt b/requirements-dev.txt
deleted file mode 100644
index e5d5d951..00000000
--- a/requirements-dev.txt
+++ /dev/null
@@ -1,13 +0,0 @@
-# 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
diff --git a/requirements-docs.txt b/requirements-docs.txt
deleted file mode 100644
index e75c15e9..00000000
--- a/requirements-docs.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-sphinx
-insegel
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 9ad05803..00000000
--- a/requirements.txt
+++ /dev/null
@@ -1 +0,0 @@
-aiohttp>=3.6.0,<3.9.0
\ No newline at end of file
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
index 00000000..08bb4b3a
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,10 @@
+indent-width = 2
+
+[format]
+docstring-code-format = true
+docstring-code-line-length = 88
+line-ending = "lf"
+quote-style = "single"
+
+[lint]
+ignore = ["E402"]
\ No newline at end of file
diff --git a/scripts/format.sh b/scripts/format.sh
deleted file mode 100644
index 4fa2e31d..00000000
--- a/scripts/format.sh
+++ /dev/null
@@ -1,2 +0,0 @@
-black .
-isort .
\ No newline at end of file
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 36733afb..00000000
--- a/setup.py
+++ /dev/null
@@ -1,64 +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.6",
- 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.6",
- "Programming Language :: Python :: 3.7",
- "Programming Language :: Python :: 3.8",
- "Programming Language :: Python :: 3.9",
- "Topic :: Internet",
- "Topic :: Software Development :: Libraries",
- "Topic :: Software Development :: Libraries :: Python Modules",
- "Topic :: Utilities",
- ],
-)
diff --git a/test.py b/test.py
new file mode 100644
index 00000000..73bbb262
--- /dev/null
+++ b/test.py
@@ -0,0 +1,87 @@
+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, offset=50, sort_by=topgg.SortBy.MONTHLY_VOTES)
+
+ for b in bots:
+ test_attributes(b)
+
+ await asyncio.sleep(1)
+ await tg.post_bot_commands([{'name': 'test', 'description': 'command description'}])
+
+ await asyncio.sleep(1)
+ 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
+
+ await asyncio.sleep(1)
+ voters = await tg.get_voters()
+
+ for voter in voters:
+ test_attributes(voter)
+
+ await asyncio.sleep(1)
+ await tg.get_vote(661200758510977084)
+
+ await asyncio.sleep(1)
+ await tg.get_vote(8226924471638491136, source=topgg.UserSource.TOPGG)
+
+ 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/test_autoposter.py b/test_autoposter.py
new file mode 100644
index 00000000..05c59c7a
--- /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.bot_autopost_retrieval
+ def get_server_count() -> int:
+ return 2
+
+ @tg.bot_autopost_success
+ def success(server_count: int) -> None:
+ print(f'Successfully posted {server_count} servers to the API!')
+
+ @tg.bot_autopost_error
+ def error(error: topgg.Error) -> None:
+ print(f'Error: {error!r}')
+
+ tg.start_bot_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/tests/test_autopost.py b/tests/test_autopost.py
deleted file mode 100644
index a4f8ee7a..00000000
--- a/tests/test_autopost.py
+++ /dev/null
@@ -1,92 +0,0 @@
-import datetime
-
-import mock
-import pytest
-from aiohttp import ClientSession
-from pytest_mock import MockerFixture
-
-from topgg import DBLClient, StatsWrapper
-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
deleted file mode 100644
index fb634ead..00000000
--- a/tests/test_client.py
+++ /dev/null
@@ -1,137 +0,0 @@
-import typing as t
-
-import mock
-import pytest
-from aiohttp import ClientSession
-
-import topgg
-from topgg import errors
-
-
-@pytest.fixture
-def session() -> ClientSession:
- return mock.Mock(ClientSession)
-
-
-@pytest.fixture
-def client() -> topgg.DBLClient:
- client = topgg.DBLClient(token="TOKEN", default_bot_id=1234)
- 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)
- 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("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)
- with pytest.raises(TypeError, match="stats or guild_count must be provided."):
- await client.post_guild_count()
-
-
-@pytest.mark.parametrize(
- "method, kwargs",
- [
- (topgg.DBLClient.get_guild_count, {}),
- (topgg.DBLClient.get_bot_info, {}),
- (
- topgg.DBLClient.generate_widget,
- {
- "options": topgg.types.WidgetOptions(),
- },
- ),
- ],
-)
-@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")
- 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_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_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={})
- 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 978574fb..00000000
--- a/tests/test_data_container.py
+++ /dev/null
@@ -1,60 +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 f1fbed6b..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 caec363c..00000000
--- a/tests/test_type.py
+++ /dev/null
@@ -1,202 +0,0 @@
-import pytest
-
-from topgg import types
-
-d: dict = {
- "defAvatar": "6debd47ed13483642cf09e832ed0bc1b",
- "invite": "",
- "website": "https://top.gg",
- "support": "KYZsaFb",
- "github": "https://github.com/top-gg/Luca",
- "longdesc": "Luca only works in the **Discord Bot List** server. \nPrepend commands with the prefix `-` or "
- "`@Luca#1375`. \n**Please refrain from using these commands in non testing channels.**\n- `botinfo "
- "@bot` Shows bot info, title redirects to site listing.\n- `bots @user`* Shows all bots of that user, "
- "includes bots in the queue.\n- `owner / -owners @bot`* Shows all owners of that bot.\n- `prefix "
- "@bot`* Shows the prefix of that bot.\n* Mobile friendly version exists. Just add `noembed` to the "
- "end of the command.\n",
- "shortdesc": "Luca is a bot for managing and informing members of the server",
- "prefix": "- or @Luca#1375",
- "lib": None,
- "clientid": "264811613708746752",
- "avatar": "7edcc4c6fbb0b23762455ca139f0e1c9",
- "id": "264811613708746752",
- "discriminator": "1375",
- "username": "Luca",
- "date": "2017-04-26T18:08:17.125Z",
- "server_count": 2,
- "guilds": ["417723229721853963", "264445053596991498"],
- "shards": [],
- "monthlyPoints": 19,
- "points": 397,
- "certifiedBot": False,
- "owners": ["129908908096487424"],
- "tags": ["Moderation", "Role Management", "Logging"],
- "donatebotguildid": "",
-}
-
-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 in ("owners", "guilds"):
- for item in bot_data[attr]:
- assert isinstance(item, int)
- assert bot_data.get(attr) == bot_data[attr] == getattr(bot_data, attr)
-
-
-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:
- 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)
-
-
-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 8ef3c71d..00000000
--- a/tests/test_webhook.py
+++ /dev/null
@@ -1,80 +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 1a9025eb..80d39f81 100644
--- a/topgg/__init__.py
+++ b/topgg/__init__.py
@@ -1,26 +1,58 @@
-# -*- coding: utf-8 -*-
-
"""
-Top.gg Python API Wrapper
-~~~~~~~~~~~~~~~~~~~~~~~~~
-A basic wrapper for the Top.gg API.
-:copyright: (c) 2021 Assanali Mukhanov & Top.gg
-:license: MIT, see LICENSE for more details.
+The MIT License (MIT)
+
+Copyright (c) 2021 Assanali Mukhanov & Top.gg
+Copyright (c) 2024-2025 null8626 & Top.gg
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
"""
-__title__ = "topggpy"
-__author__ = "Assanali Mukhanov"
-__maintainer__ = "Norizon"
-__license__ = "MIT"
-__version__ = "2.0.0a1"
+from .models import Bot, SortBy, UserSource, Vote, VoteEvent, Voter
+from .errors import Error, RequestError, Ratelimited
+from .widget import WidgetType
+from .webhooks import Webhooks
+from .version import VERSION
+from .client import Client
+from . import widget
-from .autopost import *
-from .client import *
-from .data import *
-from .errors import *
-from .http import *
-# can't be added to __all__ since they'd clash with automodule
-from .types import *
-from .types import BotVoteData, GuildVoteData
-from .webhook import *
+__title__ = 'topggpy'
+__author__ = 'null8626 & Top.gg'
+__credits__ = ('null8626', 'Top.gg')
+__maintainer__ = 'null8626'
+__status__ = 'Production'
+__license__ = 'MIT'
+__copyright__ = 'Copyright (c) 2021 Assanali Mukhanov & Top.gg; Copyright (c) 2024-2025 null8626 & Top.gg'
+__version__ = VERSION
+__all__ = (
+ 'Bot',
+ 'Client',
+ 'Error',
+ 'Ratelimited',
+ 'RequestError',
+ 'SortBy',
+ 'UserSource',
+ 'VERSION',
+ 'Vote',
+ 'VoteEvent',
+ 'Voter',
+ 'Webhooks',
+ 'widget',
+ 'WidgetType',
+)
diff --git a/topgg/autopost.py b/topgg/autopost.py
deleted file mode 100644
index 3bfe4afa..00000000
--- a/topgg/autopost.py
+++ /dev/null
@@ -1,335 +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__ = (
- "_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), shard_count=len(client.shards))
-
- # 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 (:obj:`typing.Union` [ :obj:`float`, :obj:`datetime.timedelta` ])
- The interval.
-
- Raises:
- :obj:`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 1:
- stats = await self.client._invoke_callback(self._stats)
- try:
- await self.client.post_guild_count(stats)
- except Exception as err:
- await self.client._invoke_callback(self._error, err)
- if isinstance(err, errors.Unauthorized):
- raise err from None
- else:
- on_success = getattr(self, "_success", None)
- if on_success:
- await self.client._invoke_callback(on_success)
-
- if self._stopping:
- return None
-
- 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:
- :obj:`~.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 None
-
- 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()
- return None
diff --git a/topgg/client.py b/topgg/client.py
index 0f1a72db..8aac414d 100644
--- a/topgg/client.py
+++ b/topgg/client.py
@@ -1,387 +1,736 @@
-# -*- coding: utf-8 -*-
+"""
+The MIT License (MIT)
-# 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 aiohttp import ClientResponseError, ClientSession, ClientTimeout
+from collections.abc import Iterable, Callable, Coroutine
+from typing import Any, Optional, Union
+from collections import namedtuple
+from inspect import isawaitable
+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, UserSource, Vote, Voter
+from .version import VERSION
+
+
+BASE_URL = 'https://top.gg/api'
+MAXIMUM_DELAY_THRESHOLD = 5.0
+
+
+BotAutopostRetrievalCallback = Callable[[], Union[int, Coroutine[None, None, int]]]
+BotAutopostRetrievalDecorator = Callable[
+ [BotAutopostRetrievalCallback], BotAutopostRetrievalCallback
+]
+
+BotAutopostSuccessCallback = Callable[[int], Any]
+BotAutopostSuccessDecorator = Callable[
+ [BotAutopostSuccessCallback], BotAutopostSuccessCallback
+]
+
+BotAutopostErrorCallback = Callable[[Error], Any]
+BotAutopostErrorDecorator = Callable[
+ [BotAutopostErrorCallback], BotAutopostErrorCallback
+]
+
+
+class Client:
+ """
+ Interact with the API's endpoints.
+
+ 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.
+ :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: ``token`` is not a :py:class:`str` or is empty.
+ :exception ValueError: ``token`` is not a valid API token.
+ """
+
+ id: int
+ """This project's ID."""
+
+ legacy: bool
+ """Whether this client is using a legacy API token."""
+
+ __slots__: tuple[str, ...] = (
+ '__own_session',
+ '__session',
+ '__token',
+ '__ratelimiter',
+ '__ratelimiters',
+ '__current_ratelimit',
+ '__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):
+ 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
+ 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))
+ encoded_json = json.loads(b64decode(encoded_json))
+
+ 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
+
+ endpoint_ratelimits = namedtuple('EndpointRatelimits', 'global_ bot')
+
+ self.__ratelimiter = endpoint_ratelimits(
+ global_=Ratelimiter(99, 1), bot=Ratelimiter(59, 60)
+ )
+ self.__ratelimiters = Ratelimiters(self.__ratelimiter)
+ self.__current_ratelimit = None
+
+ 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}>'
+
+ def __int__(self) -> int:
+ return self.id
+
+ async def __request(
+ self,
+ method: str,
+ path: str,
+ params: Optional[dict] = None,
+ body: Optional[dict] = None,
+ ) -> 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.__ratelimiters if path.startswith('/bots') else self.__ratelimiter.global_
+ )
+
+ kwargs = {}
+
+ if body:
+ kwargs['json'] = body
+
+ if params:
+ kwargs['params'] = params
+
+ status = None
+ retry_after = None
+ output = None
+
+ async with ratelimiter:
+ try:
+ async with self.__session.request(
+ method,
+ BASE_URL + path,
+ headers={
+ 'Authorization': f'Bearer {self.__token}',
+ 'Content-Type': 'application/json',
+ 'User-Agent': f'topggpy (https://github.com/top-gg-community/python-sdk {VERSION}) Python/',
+ },
+ **kwargs,
+ ) as resp:
+ status = resp.status
+ retry_after = float(resp.headers.get('Retry-After', 0))
+
+ if 'json' in resp.headers['Content-Type']:
+ try:
+ output = await resp.json()
+ except json.decoder.JSONDecodeError:
+ pass
+
+ resp.raise_for_status()
+
+ return output
+ except ClientResponseError:
+ if status == 429:
+ if retry_after > MAXIMUM_DELAY_THRESHOLD:
+ self.__current_ratelimit = time() + retry_after
+
+ raise Ratelimited(retry_after) from None
+
+ await asyncio.sleep(retry_after)
+
+ return await self.__request(method, path)
+
+ raise RequestError(
+ output and output.get('message', output.get('detail')), status
+ ) from None
+
+ async def get_bot(self, id: int) -> Bot:
+ """
+ Fetches a Discord bot from its 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.
+ :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: :class:`.Bot`
+ """
+
+ return Bot(await self.__request('GET', f'/bots/{id}'))
+
+ async def get_bots(
+ self,
+ *,
+ limit: Optional[int] = None,
+ offset: Optional[int] = None,
+ sort_by: Optional[SortBy] = None,
+ ) -> Iterable[Bot]:
+ """
+ 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)
+
+ 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: 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: The requested bots.
+ :rtype: Iterable[:class:`.Bot`]
+ """
+
+ 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
+
+ bots = await self.__request('GET', '/bots', params=params)
+
+ return map(Bot, bots.get('results', ()))
+
+ async def get_bot_server_count(self) -> Optional[int]:
+ """
+ Fetches your Discord bot's posted server count.
+
+ Example:
+
+ .. code-block:: python
+
+ 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.
+ :exception Ratelimited: Ratelimited from sending more requests.
+
+ :returns: The posted server count.
+ :rtype: Optional[:py:class:`int`]
+ """
+
+ stats = await self.__request('GET', '/bots/stats')
+
+ return stats.get('server_count')
+
+ async def post_bot_server_count(self, new_server_count: int) -> None:
+ """
+ Updates the server count in your Discord bot's Top.gg page.
+
+ Example:
+
+ .. code-block:: python
+
+ 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`
-# Copyright (c) 2021 Assanali Mukhanov
+ :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.
+ """
+
+ if new_server_count <= 0:
+ raise ValueError(
+ f'Posted server count cannot be zero or lower, got {new_server_count}.'
+ )
+
+ await self.__request('POST', '/bots/stats', body={'server_count': new_server_count})
+
+ 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.
+
+ :returns: Whether the weekend multiplier is active.
+ :rtype: :py:class:`bool`
+ """
-# 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:
+ response = await self.__request('GET', '/weekend')
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
+ return response['is_weekend']
-# 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.
+ async def get_voters(self, page: int = 1) -> Iterable[Voter]:
+ """
+ Fetches your project's recent 100 unique voters.
-__all__ = ["DBLClient"]
+ Examples:
-import typing as t
+ .. code-block:: python
-import aiohttp
+ # First page
+ voters = await client.get_voters()
-from . import errors, types
-from .autopost import AutoPoster
-from .data import DataContainerMixin
-from .http import HTTPClient
+ # Subsequent pages
+ voters = await client.get_voters(2)
+ for voter in voters:
+ print(voter)
-class DBLClient(DataContainerMixin):
- """Represents a client connection that connects to Top.gg.
+ :param page: The page number. Each page can only have at most 100 voters. Defaults to 1.
+ :type page: :py:class:`int`
- This class is used to interact with the Top.gg 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.
- .. _aiohttp session: https://aiohttp.readthedocs.io/en/stable/client_reference.html#client-session
+ :returns: The requested voters.
+ :rtype: Iterable[:class:`.Voter`]
+ """
- Args:
- token (:obj:`str`): Your bot's Top.gg API Token.
+ return map(
+ Voter,
+ await self.__request(
+ 'GET', f'/bots/{self.id}/votes', params={'page': max(page, 1)}
+ ),
+ )
- 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.
+ async def has_voted(self, id: int) -> bool:
"""
+ .. 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:
+
+ .. code-block:: python
+
+ 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 user has voted in the past 12 hours.
+ :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 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.__bot_autopost_retrieval_callback()
+
+ if isawaitable(server_count):
+ server_count = await server_count
+
+ await self.post_bot_server_count(server_count)
+
+ for success_callback in self.__bot_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.__bot_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 bot_autopost_retrieval(
+ self, callback: Optional[BotAutopostRetrievalCallback] = None
+ ) -> Union[BotAutopostRetrievalCallback, BotAutopostRetrievalDecorator]:
+ """
+ Registers a bot autopost server count retrieval callback.
+
+ Example:
+
+ .. code-block:: python
+
+ @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.BotAutopostRetrievalCallback`]
+
+ :returns: The function itself or a decorated function depending on the argument.
+ :rtype: Union[:data:`~.client.BotAutopostRetrievalCallback`, :data:`~.client.BotAutopostRetrievalDecorator`]
+ """
+
+ def decorator(
+ callback: BotAutopostRetrievalCallback,
+ ) -> BotAutopostRetrievalCallback:
+ self.__bot_autopost_retrieval_callback = callback
+
+ return callback
+
+ if callback is not None:
+ decorator(callback)
+
+ return callback
+
+ return decorator
+
+ def bot_autopost_success(
+ self, callback: Optional[BotAutopostSuccessCallback] = None
+ ) -> Union[BotAutopostSuccessCallback, BotAutopostSuccessDecorator]:
+ """
+ Registers a bot autopost on success callback. Several callbacks are possible.
+
+ Example:
+
+ .. code-block:: python
+
+ @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.BotAutopostSuccessCallback`]
+
+ :returns: The function itself or a decorated function depending on the argument.
+ :rtype: Union[:data:`~.client.BotAutopostSuccessCallback`, :data:`~.client.BotAutopostSuccessDecorator`]
+ """
+
+ def decorator(callback: BotAutopostSuccessCallback) -> BotAutopostSuccessCallback:
+ self.__bot_autopost_success_callbacks.add(callback)
+
+ return callback
+
+ if callback is not None:
+ decorator(callback)
+
+ return self
+
+ return decorator
+
+ def bot_autopost_error(
+ self, callback: Optional[BotAutopostErrorCallback] = None
+ ) -> Union[BotAutopostErrorCallback, BotAutopostErrorDecorator]:
+ """
+ Registers a bot autopost on error handler. Several callbacks are possible.
+
+ Example:
+
+ .. code-block:: python
+
+ @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.BotAutopostErrorCallback`]
+
+ :returns: The function itself or a decorated function depending on the argument.
+ :rtype: Union[:data:`~.client.BotAutopostErrorCallback`, :data:`~.client.BotAutopostErrorDecorator`]
+ """
+
+ def decorator(callback: BotAutopostErrorCallback) -> BotAutopostErrorCallback:
+ self.__bot_autopost_error_callbacks.add(callback)
+
+ return callback
+
+ if callback is not None:
+ decorator(callback)
+
+ return self
+
+ return decorator
+
+ @property
+ def bot_autoposter_running(self) -> bool:
+ """Whether the Discord bot autoposter is running."""
+
+ return self.__bot_autopost_task is not None
+
+ def start_bot_autoposter(self, interval: Optional[float] = None) -> None:
+ """
+ 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_bot_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.
+ """
+
+ if not self.bot_autoposter_running:
+ assert (
+ self.__bot_autopost_retrieval_callback is not None
+ ), 'Missing bot_autopost_retrieval callback.'
+
+ self.__bot_autopost_task = asyncio.create_task(self.__bot_autopost_loop(interval))
+
+ def stop_bot_autoposter(self) -> None:
+ """
+ Stops the Discord bot autoposter.
+
+ Example:
+
+ .. code-block:: python
+
+ client.stop_bot_autoposter()
+ """
+
+ if self.bot_autoposter_running:
+ self.__bot_autopost_task.cancel()
+ self.__bot_autopost_task = None
+
+ async def close(self) -> None:
+ """
+ Closes the client.
+
+ Example:
+
+ .. code-block:: python
+
+ await client.close()
+ """
+
+ self.stop_bot_autoposter()
+
+ if self.__own_session and not self.__session.closed:
+ await self.__session.close()
+
+ async def __aenter__(self) -> 'Client':
+ return self
- __slots__ = ("http", "default_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
- 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)
-
- 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.
-
- Returns:
- :obj:`bool`: The boolean value of weekend status.
-
- Raises:
- :obj:`~.errors.ClientStateException`
- If the client has been closed.
- """
- await self._ensure_session()
- 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:
- """Posts your bot's guild count and shards info 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, shard_count, and shard_id.
-
- Keyword Arguments:
- guild_count (:obj:`typing.Optional` [:obj:`typing.Union` [ :obj:`int`, :obj:`list` [ :obj:`int` ]]])
- Number of guilds the bot is in. Applies the number to a shard instead if shards are specified.
- If not specified, length of provided client's property `.guilds` will be posted.
- shard_count (:obj:`.typing.Optional` [ :obj:`int` ])
- The total number of shards.
- shard_id (:obj:`.typing.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`
- If the client has been closed.
- """
- if stats:
- guild_count = stats.guild_count
- shard_count = stats.shard_count
- shard_id = stats.shard_id
- elif guild_count is None:
- raise TypeError("stats or guild_count must be provided.")
- 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.
-
- 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)
- 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:
- :obj:`list` [ :obj:`~.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)
- 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 the provided Client object.
-
- Returns:
- :obj:`~.types.BotData`:
- Information on the bot you looked up. Returned data can be found
- `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)
- 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]:
- """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.
- """
- 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:
- :obj:`~.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:
- :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)
- 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:
- :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.")
-
- 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
-
- 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
-
- 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 7126d3bf..00000000
--- a/topgg/data.py
+++ /dev/null
@@ -1,145 +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), shard_count=len(client.shards))
- """
- return t.cast(T, Data(type_))
-
-
-class Data(t.Generic[T]):
- __slots__ = ("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__ = ("_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_` (:obj:`typing.Any`)
- The data to be injected.
- override (:obj:`bool`)
- Whether or not to override another instance that already exists.
-
- Raises:
- :obj:`~.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 d8c157a5..995c13c6 100644
--- a/topgg/errors.py
+++ b/topgg/errors.py
@@ -1,103 +1,72 @@
-# -*- coding: utf-8 -*-
+"""
+The MIT License (MIT)
-# The MIT License (MIT)
+Copyright (c) 2021 Assanali Mukhanov & Top.gg
+Copyright (c) 2024-2025 null8626 & Top.gg
-# 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:
-# 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.
+from typing import Optional
-__all__ = [
- "TopGGException",
- "ClientException",
- "ClientStateException",
- "HTTPException",
- "Unauthorized",
- "UnauthorizedDetected",
- "Forbidden",
- "NotFound",
- "ServerError",
-]
-from typing import TYPE_CHECKING, Union
+class Error(Exception):
+ """An error coming from this SDK. Extends :py:class:`Exception`."""
-if TYPE_CHECKING:
- from aiohttp import ClientResponse
+ __slots__: tuple[str, ...] = ()
-class TopGGException(Exception):
- """Base exception class for topggpy.
+class RequestError(Error):
+ """HTTP request failure. Extends :class:`.Error`."""
- Ideally speaking, this could be caught to handle any exceptions thrown from this library.
- """
+ __slots__: tuple[str, ...] = ('message', 'status')
+ message: Optional[str]
+ """The message returned from the API."""
-class ClientException(TopGGException):
- """Exception that's thrown when an operation in the :class:`~.DBLClient` fails.
+ status: Optional[int]
+ """The status code returned from the API."""
- These are usually for exceptions that happened due to user input.
- """
+ def __init__(self, message: Optional[str], status: Optional[int]):
+ self.message = message
+ self.status = status
+ super().__init__(f'Got {status}: {self.message!r}')
-class ClientStateException(ClientException):
- """Exception that's thrown when an operation happens in a closed :obj:`~.DBLClient` instance."""
+ 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.
+class Ratelimited(Error):
+ """Ratelimited from sending more requests. Extends :class:`.Error`."""
- Attributes:
- response (:class:`aiohttp.ClientResponse`)
- The response of the failed HTTP request.
- text (str)
- The text of the error. Could be an empty string.
- """
+ __slots__: tuple[str, ...] = ('retry_after',)
- 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
+ retry_after: float
+ """How long the client should wait in seconds before it could send requests again without receiving a 429."""
- fmt = f"{self.response.reason} (status code: {self.response.status})"
- if self.text:
- fmt = f"{fmt}: {self.text}"
+ def __init__(self, retry_after: float):
+ self.retry_after = retry_after
- super().__init__(fmt)
+ super().__init__(
+ f'Blocked from sending more requests, 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 08160d67..00000000
--- a/topgg/http.py
+++ /dev/null
@@ -1,252 +0,0 @@
-# -*- coding: utf-8 -*-
-
-# The MIT License (MIT)
-
-# Copyright (c) 2021 Assanali Mukhanov
-
-# Permission is hereby granted, free of charge, to any person obtaining a
-# copy of this software and associated documentation files (the "Software"),
-# to deal in the Software without restriction, including without limitation
-# the rights to use, copy, modify, merge, publish, distribute, sublicense,
-# and/or sell copies of the Software, and to permit persons to whom the
-# Software is furnished to do so, subject to the following conditions:
-
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-# DEALINGS IN THE SOFTWARE.
-
-__all__ = ["HTTPClient"]
-
-import asyncio
-import json
-import logging
-import sys
-from datetime import datetime
-from typing import Any, Coroutine, Dict, Iterable, List, Optional, Sequence, Union, cast
-
-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
-
-
-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`.
- """
-
- def __init__(
- self,
- token: str,
- *,
- 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)
- 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/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 = f"{self.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[Union[int, List[int]]],
- shard_count: Optional[int],
- shard_id: Optional[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)
-
- 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]:
- """Gets an object of bots on Top.gg."""
- 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 - datetime.utcnow().timestamp())
- 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)
diff --git a/topgg/models.py b/topgg/models.py
new file mode 100644
index 00000000..7fc4f638
--- /dev/null
+++ b/topgg/models.py
@@ -0,0 +1,293 @@
+"""
+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 datetime import datetime, timezone
+from typing import Optional, TypeVar
+from urllib import parse
+from enum import Enum
+
+
+T = TypeVar('T')
+
+
+def truthy_only(value: Optional[T]) -> Optional[T]:
+ if value:
+ return value
+
+
+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 project 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."""
+
+ __slots__: tuple[str, ...] = ('id', 'username', 'avatar')
+
+ id: int
+ """This voter's ID."""
+
+ username: str
+ """This voter's username."""
+
+ avatar: str
+ """This voter's avatar URL."""
+
+ def __init__(self, json: dict):
+ self.id = int(json['id'])
+ self.username = json['username']
+ self.avatar = json['avatar']
+
+ def __repr__(self) -> str:
+ return f'<{__class__.__name__} id={self.id} username={self.username!r}>'
+
+ def __int__(self) -> int:
+ return self.id
+
+ def __eq__(self, other: 'Voter') -> bool:
+ if isinstance(other, __class__):
+ return self.id == other.id
+
+ return NotImplemented
+
+
+class Bot:
+ """A Discord bot listed on Top.gg."""
+
+ __slots__: tuple[str, ...] = (
+ 'id',
+ 'topgg_id',
+ 'username',
+ 'prefix',
+ 'short_description',
+ 'long_description',
+ 'tags',
+ 'website',
+ 'github',
+ 'owners',
+ 'submitted_at',
+ 'votes',
+ 'monthly_votes',
+ 'support',
+ 'avatar',
+ 'invite',
+ 'vanity',
+ 'server_count',
+ 'review_score',
+ 'review_count',
+ )
+
+ id: int
+ """This bot's Discord ID."""
+
+ topgg_id: int
+ """This bot's Top.gg ID."""
+
+ username: 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 HTML/Markdown long description."""
+
+ 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]
+ """This bot's owner IDs."""
+
+ submitted_at: datetime
+ """This bot's submission date."""
+
+ 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 URL."""
+
+ avatar: str
+ """This bot's avatar 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."""
+
+ review_score: float
+ """This bot's average review score out of 5."""
+
+ review_count: int
+ """This bot's review count."""
+
+ def __init__(self, json: dict):
+ self.id = int(json['clientid'])
+ self.topgg_id = int(json['id'])
+ self.username = json['username']
+ self.prefix = json['prefix']
+ self.short_description = json['shortdesc']
+ self.long_description = truthy_only(json.get('longdesc'))
+ 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['owners']]
+ self.submitted_at = datetime.fromisoformat(json['date'].replace('Z', '+00:00'))
+ self.votes = json['points']
+ self.monthly_votes = json['monthlyPoints']
+ self.support = truthy_only(json.get('support'))
+ self.avatar = json['avatar']
+ 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']
+
+ def __repr__(self) -> str:
+ 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
+
+ def __eq__(self, other: 'Bot') -> bool:
+ if isinstance(other, __class__):
+ return self.id == other.id
+
+ return NotImplemented
+
+
+class SortBy(Enum):
+ """Supported sorting criterias in :meth:`.Client.get_bots`."""
+
+ __slots__: tuple[str, ...] = ()
+
+ ID = 'id'
+ """Sorts results based on each bot's ID."""
+
+ SUBMISSION_DATE = 'date'
+ """Sorts results based on each bot's submission date."""
+
+ MONTHLY_VOTES = 'monthlyPoints'
+ """Sorts results based on each bot's monthly vote count."""
+
+
+class 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/py.typed b/topgg/py.typed
deleted file mode 100644
index e69de29b..00000000
diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py
index 028a98ee..44beb05f 100644
--- a/topgg/ratelimiter.py
+++ b/topgg/ratelimiter.py
@@ -1,110 +1,110 @@
-# -*- coding: utf-8 -*-
+"""
+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 Iterable
+from types import TracebackType
+from collections import deque
+from time import time
+import asyncio
-# The MIT License (MIT)
-# Copyright (c) 2021 Assanali Mukhanov
+class Ratelimiter:
+ """Handles ratelimits for a specific endpoint."""
-# 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:
+ __slots__: tuple[str, ...] = ('__lock', '__max_calls', '__period', '__calls')
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
+ 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()
-# 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.
+ async def __aenter__(self) -> 'Ratelimiter':
+ """Delays the request to this endpoint if it could lead to a ratelimit."""
-import asyncio
-import collections
-from datetime import datetime
-from types import TracebackType
-from typing import Any, Awaitable, Callable, List, Optional, Type
-
-
-class AsyncRateLimiter:
- """
- Provides rate limiting for an operation with a configurable number of requests for a time period.
- """
-
- __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 = datetime.utcnow().timestamp() + self.period - self._timespan
- if self.callback:
- asyncio.ensure_future(self.callback(until))
- sleep_time = until - datetime.utcnow().timestamp()
- if sleep_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(datetime.utcnow().timestamp())
-
- while self._timespan >= self.period:
- self.calls.popleft()
-
- @property
- def _timespan(self) -> float:
- return self.calls[-1] - self.calls[0]
-
-
-class AsyncRateLimiterManager:
- rate_limiters: List[AsyncRateLimiter]
-
- 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]
- 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
- ]
- )
+ 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:
+ """Stores the previous request's timestamp."""
+
+ async with self.__lock:
+ self.__calls.append(time())
+
+ while self._timespan >= self.__period:
+ self.__calls.popleft()
+
+ @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':
+ """Delays the request to this endpoint if it could lead to a ratelimit."""
+
+ for ratelimiter in self.__ratelimiters:
+ await ratelimiter.__aenter__()
+
+ return self
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException],
+ exc_val: BaseException,
+ exc_tb: TracebackType,
+ ) -> None:
+ """Stores the previous request's timestamp."""
+
+ await asyncio.gather(
+ *(
+ ratelimiter.__aexit__(exc_type, exc_val, exc_tb)
+ for ratelimiter in self.__ratelimiters
+ )
+ )
diff --git a/topgg/types.py b/topgg/types.py
deleted file mode 100644
index 2da13f95..00000000
--- a/topgg/types.py
+++ /dev/null
@@ -1,397 +0,0 @@
-# -*- coding: utf-8 -*-
-
-# The MIT License (MIT)
-
-# Copyright (c) 2021 Assanali Mukhanov
-
-# Permission is hereby granted, free of charge, to any person obtaining a
-# copy of this software and associated documentation files (the "Software"),
-# to deal in the Software without restriction, including without limitation
-# the rights to use, copy, modify, merge, publish, distribute, sublicense,
-# and/or sell copies of the Software, and to permit persons to whom the
-# Software is furnished to do so, subject to the following conditions:
-
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-# DEALINGS IN THE SOFTWARE.
-
-__all__ = ["WidgetOptions", "StatsWrapper"]
-
-import dataclasses
-import typing as t
-from datetime import datetime
-
-KT = t.TypeVar("KT")
-VT = t.TypeVar("VT")
-Colors = t.Dict[str, int]
-Colours = Colors
-
-
-def camel_to_snake(string: str) -> str:
- return "".join(["_" + c.lower() if c.isupper() else c for c in string]).lstrip("_")
-
-
-def parse_vote_dict(d: dict) -> dict:
- data = d.copy()
-
- query = data.get("query", "").lstrip("?")
- if query:
- query_dict = {k: v for k, v in [pair.split("=") for pair in query.split("&")]}
- data["query"] = DataDict(**query_dict)
- else:
- data["query"] = {}
-
- if "bot" in data:
- data["bot"] = int(data["bot"])
-
- elif "guild" in data:
- data["guild"] = int(data["guild"])
-
- for key, value in data.copy().items():
- converted_key = camel_to_snake(key)
- if key != converted_key:
- del data[key]
- data[converted_key] = value
-
- return data
-
-
-def parse_dict(d: dict) -> dict:
- data = d.copy()
-
- for key, value in data.copy().items():
- if "id" in key.lower():
- if value == "":
- value = None
- else:
- if isinstance(value, str) and value.isdigit():
- value = int(value)
- else:
- continue
- elif value == "":
- value = None
-
- converted_key = camel_to_snake(key)
- if key != converted_key:
- del data[key]
- data[converted_key] = value
-
- return data
-
-
-def parse_bot_dict(d: dict) -> dict:
- data = parse_dict(d.copy())
-
- if data.get("date") and not isinstance(data["date"], datetime):
- data["date"] = datetime.strptime(data["date"], "%Y-%m-%dT%H:%M:%S.%fZ")
-
- if data.get("owners"):
- data["owners"] = [int(e) for e in data["owners"]]
- if data.get("guilds"):
- data["guilds"] = [int(e) for e in data["guilds"]]
-
- for key, value in data.copy().items():
- converted_key = camel_to_snake(key)
- if key != converted_key:
- del data[key]
- data[converted_key] = value
-
- return data
-
-
-def parse_user_dict(d: dict) -> dict:
- data = d.copy()
-
- 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
- if "shards" not in data:
- data["shards"] = []
- if "shard_count" not in data:
- data["shard_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`."""
-
- 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
- `__."""
-
- id: int
- """The ID of the bot."""
-
- 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: str
- """The avatar hash of the bot's default avatar."""
-
- 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."""
-
- 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."""
-
- 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 and shard count."""
-
- 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))
-
-
-class BriefUserData(DataDict[str, t.Any]):
- """Model that contains brief information about a Top.gg user."""
-
- 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."""
-
- 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
- `__."""
-
- id: int
- """The ID of the user."""
-
- username: str
- """The username of the user."""
-
- discriminator: str
- """The discriminator 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."""
-
- certified_dev: bool
- """Whether or not the user is a certified dev."""
-
- 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."""
-
- 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."""
-
- 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."""
-
- guild: int
- """ID of the guild the user voted for."""
-
-
-ServerVoteData = GuildVoteData
-
-
-@dataclasses.dataclass
-class StatsWrapper:
- guild_count: int
- """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."""
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'
diff --git a/topgg/webhook.py b/topgg/webhook.py
deleted file mode 100644
index 4b94ec2b..00000000
--- a/topgg/webhook.py
+++ /dev/null
@@ -1,368 +0,0 @@
-# -*- coding: utf-8 -*-
-
-# The MIT License (MIT)
-
-# Copyright (c) 2021 Assanali Mukhanov
-
-# Permission is hereby granted, free of charge, to any person obtaining a
-# copy of this software and associated documentation files (the "Software"),
-# to deal in the Software without restriction, including without limitation
-# the rights to use, copy, modify, merge, publish, distribute, sublicense,
-# and/or sell copies of the Software, and to permit persons to whom the
-# Software is furnished to do so, subject to the following conditions:
-
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
-# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-# DEALINGS IN THE SOFTWARE.
-
-__all__ = [
- "endpoint",
- "BoundWebhookEndpoint",
- "WebhookEndpoint",
- "WebhookManager",
- "WebhookType",
-]
-
-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."""
-
- 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.
- """
-
- __app: web.Application
- _webserver: web.TCPSite
- _is_closed: bool
- __slots__ = ("__app", "_webserver", "_is_running")
-
- def __init__(self) -> None:
- super().__init__()
- self.__app = web.Application()
- self._is_running = False
-
- @t.overload
- def endpoint(self, endpoint_: None = None) -> "BoundWebhookEndpoint":
- ...
-
- @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_` (:obj:`typing.Optional` [ :obj:`WebhookEndpoint` ])
- The endpoint to add.
-
- Returns:
- :obj:`typing.Union` [ :obj:`WebhookManager`, :obj:`BoundWebhookEndpoint` ]:
- An instance of :obj:`WebhookManager` if endpoint was provided,
- otherwise :obj:`BoundWebhookEndpoint`.
-
- Raises:
- :obj:`~.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()
- 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__ = ("_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__ = ("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:
- :obj:`typing.Callable` [[ :obj:`typing.Callable` [..., :obj:`typing.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
diff --git a/topgg/webhooks.py b/topgg/webhooks.py
new file mode 100644
index 00000000..ba8df407
--- /dev/null
+++ b/topgg/webhooks.py
@@ -0,0 +1,196 @@
+"""
+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 aiohttp import web, ContentTypeError
+from typing import Any, Optional, Union
+from json import JSONDecodeError
+from inspect import isawaitable
+
+from .models import VoteEvent
+
+
+RawCallback = Callable[[web.Request], Awaitable[web.StreamResponse]]
+OnVoteCallback = Callable[[VoteEvent], Any]
+OnVoteDecorator = Callable[[OnVoteCallback], RawCallback]
+
+
+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.
+ :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 __repr__(self) -> str:
+ 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]:
+ """
+ Registers a route that gets called whenever your project receives a vote.
+
+ Example:
+
+ .. code-block:: python
+
+ @webhooks.on_vote('/votes')
+ 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.
+ :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: 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`]
+ """
+
+ if not isinstance(route, str) or not route:
+ raise TypeError('Missing route argument.')
+
+ auth = auth or self.__default_auth
+
+ assert auth is not None, 'Missing password.'
+
+ 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')
+
+ try:
+ vote = VoteEvent(await request.json())
+ except (JSONDecodeError, ContentTypeError):
+ return web.Response(status=400, text='Bad request')
+
+ response = inner_callback(vote)
+
+ if isawaitable(response):
+ await response
+
+ return web.Response(status=204, text='')
+
+ 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.
+
+ 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`]
+
+ :exception TypeError: the port is not provided either here or in the constructor call.
+ """
+
+ if not self.running:
+ port = port or self.__default_port
+
+ assert port is not None, '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.
+
+ Example:
+
+ .. code-block:: python
+
+ await webhooks.close()
+ """
+
+ if self.running:
+ await self.__server.stop()
+
+ self.__running = False
+
+ @property
+ def 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()
diff --git a/topgg/widget.py b/topgg/widget.py
new file mode 100644
index 00000000..36033b02
--- /dev/null
+++ b/topgg/widget.py
@@ -0,0 +1,100 @@
+from .client import BASE_URL
+
+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.
+
+ 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.
+ :type id: :py:class:`int`
+
+ :returns: The widget URL.
+ :rtype: :py:class:`str`
+ """
+
+ return f'{BASE_URL}/v1/widgets/large/{type.value}/{id}'
+
+
+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.
+ :type id: :py:class:`int`
+
+ :returns: The widget URL.
+ :rtype: :py:class:`str`
+ """
+
+ 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 a project'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.
+ :type id: :py:class:`int`
+
+ :returns: The widget URL.
+ :rtype: :py:class:`str`
+ """
+
+ return f'{BASE_URL}/v1/widgets/small/owner/{type.value}/{id}'
+
+
+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.
+ :type id: :py:class:`int`
+
+ :returns: The widget URL.
+ :rtype: :py:class:`str`
+ """
+
+ return f'{BASE_URL}/v1/widgets/small/social/{type.value}/{id}'