diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..44b4224 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* eol=lf \ No newline at end of file diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 60b37a8..473cba8 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ 3.6, 3.7, 3.8, 3.9 ] + python-version: [ 3.8, 3.9, '3.10', 3.11, 3.12 ] steps: - uses: actions/checkout@v2 @@ -29,13 +29,10 @@ jobs: path: "requirements-dev.txt" - name: Install itself run: | - python setup.py install - - name: Lint with flake8 + python -m pip install . + - name: Lint with ruff run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics + ruff check - name: Test with pytest run: | pytest diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 1eba4d8..5ff91b0 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -17,15 +17,15 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: '3.7' + python-version: '3.12' - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine -r requirements.txt - name: Build and publish env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_USERNAME: '__token__' TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | python setup.py sdist bdist_wheel - twine upload dist/* + twine upload dist/* \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3d7edd2..51f781c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,9 @@ -dblpy.egg-info/ topggpy.egg-info/ -topgg/__pycache__/ build/ dist/ -/docs/_build -/docs/_templates -.vscode -/.idea/ -__pycache__ +docs/_build +docs/_templates +.vscode/ +.idea/ +**/__pycache__/ .coverage diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index 317f1d3..0000000 --- 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 index 388f9a1..35fe322 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -7,7 +7,7 @@ build: image: latest python: - version: 3.8 + version: 3.12 install: - requirements: requirements.txt - requirements: requirements-docs.txt diff --git a/LICENSE b/LICENSE index 96aaaf8..c4cd04c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,5 @@ Copyright 2021 Assanali Mukhanov & Top.gg +Copyright 2024 null8626 & Top.gg Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/MANIFEST.in b/MANIFEST.in index dc068af..5dec6b1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,16 @@ -include LICENSE -include requirements.txt -include README.rst +prune .github +prune .ruff_cache +prune docs +prune examples +prune scripts +prune tests +exclude .gitattributes +exclude .gitignore +exclude .readthedocs.yml +exclude mypy.ini +exclude pytest.ini +exclude requirements-dev.txt +exclude requirements-docs.txt +exclude ruff.toml +exclude ISSUE_TEMPLATE.md +exclude PULL_REQUEST_TEMPLATE.md \ No newline at end of file diff --git a/README.rst b/README.rst index 5bdddce..928930b 100644 --- a/README.rst +++ b/README.rst @@ -4,42 +4,30 @@ 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 + :alt: View on PyPI +.. image:: https://img.shields.io/pypi/dm/topggpy?style=flat-square :target: https://topggpy.readthedocs.io/en/latest/?badge=latest - :alt: Documentation Status + :alt: Monthly PyPI downloads -A simple API wrapper for `Top.gg `_ written in Python, supporting discord.py. +A simple API wrapper for `Top.gg `_ written in Python. 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/ + pip install topggpy Documentation ------------- -Documentation can be found `here `_ +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 diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css deleted file mode 100644 index c7e6795..0000000 --- a/docs/_static/css/custom.css +++ /dev/null @@ -1,7 +0,0 @@ -header #logo-container img { - height: 100px; -} - -#search input[type="text"] { - font-size: 1em; -} \ No newline at end of file diff --git a/docs/_static/favicon.ico b/docs/_static/favicon.ico new file mode 100644 index 0000000..ad108bb Binary files /dev/null and b/docs/_static/favicon.ico differ diff --git a/docs/_static/img/favicon-16x16.png b/docs/_static/img/favicon-16x16.png deleted file mode 100644 index 58c60e9..0000000 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 0000000..c8cfd5b --- /dev/null +++ b/docs/_static/script.js @@ -0,0 +1,20 @@ +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 + } +}) + +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 0000000..ee1e4dd --- /dev/null +++ b/docs/_static/style.css @@ -0,0 +1,48 @@ +body { + --color-link-underline: rgba(0, 0, 0, 0); + --color-link-underline--hover: var(--color-link); + --color-inline-code-background: rgba(0, 0, 0, 0); + --color-api-background-hover: var(--color-background-primary); + --color-highlight-on-target: var(--color-background-primary) !important; + + --font-stack: "Inter", sans-serif !important; + --font-stack--monospace: "Roboto Mono", monospace !important; +} + +aside.toc-drawer { + visibility: hidden; +} + +#furo-readthedocs-versions, .injected, .edit-this-page, .related-pages, .headerlink { + visibility: hidden; + user-select: none; +} + +dd dt { + color: var(--color-foreground-secondary); +} + +aside.toc-drawer .docutils:hover, .sidebar-brand-text:hover { + transition: 0.15s; + filter: opacity(75%); +} + +.highlight .c1, em { + font-style: normal !important; +} + +.highlight .nn { + text-decoration: none !important; +} + +h1 { + font-weight: 900; +} + +.sidebar-brand-text { + font-weight: bolder; +} + +.sidebar-scroll .reference.internal { + color: var(--color-brand-primary); +} \ No newline at end of file diff --git a/docs/api.rst b/docs/api.rst deleted file mode 100644 index 6969165..0000000 --- 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 index 668af79..0151646 100644 --- a/docs/api/autopost.rst +++ b/docs/api/autopost.rst @@ -1,6 +1,6 @@ -####################### -Auto-post API Reference -####################### +################## +Autopost reference +################## .. automodule:: topgg.autopost :members: diff --git a/docs/api/client.rst b/docs/api/client.rst index 1bac197..1d8966e 100644 --- a/docs/api/client.rst +++ b/docs/api/client.rst @@ -1,6 +1,6 @@ -#################### -Client API Reference -#################### +################ +Client reference +################ .. automodule:: topgg.client :members: diff --git a/docs/api/data.rst b/docs/api/data.rst index 3f10ff2..090494e 100644 --- a/docs/api/data.rst +++ b/docs/api/data.rst @@ -1,6 +1,6 @@ -################## -Data API Reference -################## +############## +Data reference +############## .. automodule:: topgg.data :members: diff --git a/docs/api/errors.rst b/docs/api/errors.rst index 804fdfa..d54af67 100644 --- a/docs/api/errors.rst +++ b/docs/api/errors.rst @@ -1,7 +1,6 @@ -#################### -Errors API Reference -#################### +################ +Errors reference +################ .. automodule:: topgg.errors - :members: - :inherited-members: \ No newline at end of file + :members: \ No newline at end of file diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 0000000..4c4e05b --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,13 @@ +############# +API reference +############# + + .. toctree:: + :maxdepth: 2 + + autopost + client + data + errors + types + webhook \ No newline at end of file diff --git a/docs/api/types.rst b/docs/api/types.rst index a6a70f8..14b983d 100644 --- a/docs/api/types.rst +++ b/docs/api/types.rst @@ -1,6 +1,6 @@ -#################### -Models API Reference -#################### +################ +Models reference +################ .. automodule:: topgg.types :members: diff --git a/docs/api/webhook.rst b/docs/api/webhook.rst index 53a41c9..c1b067a 100644 --- a/docs/api/webhook.rst +++ b/docs/api/webhook.rst @@ -1,6 +1,6 @@ -##################### -Webhook API Reference -##################### +################# +Webhook reference +################# .. automodule:: topgg.webhook :members: diff --git a/docs/conf.py b/docs/conf.py index 2d36857..50eaf9d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,23 +21,10 @@ import os import sys -import alabaster - sys.path.insert(0, os.path.abspath("../")) -from topgg import __version__ as version - -# import re +from topgg import __version__ as version -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. extensions = [ "sphinx.ext.autodoc", "sphinx.ext.viewcode", @@ -45,12 +32,13 @@ "sphinx.ext.extlinks", "sphinx.ext.intersphinx", "sphinx.ext.napoleon", + "sphinx_reredirects", ] autodoc_member_order = "groupwise" extlinks = { - "issue": ("https://github.com/top-gg/python-sdk/issues/%s", "GH-"), + "issue": ("https://github.com/top-gg-community/python-sdk/issues/%s", "#%s"), } intersphinx_mapping = { @@ -59,167 +47,46 @@ "aiohttp": ("https://docs.aiohttp.org/en/stable/", None), } -releases_github_path = "top-gg/python-sdk" - -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] +redirects = { + "repository": "https://github.com/top-gg-community/python-sdk", + "support": "https://discord.gg/dbl", + "api/index": "autopost.html", + "examples/index": "discord_py.html", + "examples/discord_py": "https://github.com/Top-gg-Community/python-sdk/tree/master/examples/discordpy_example", + "examples/hikari": "https://github.com/Top-gg-Community/python-sdk/tree/master/examples/hikari_example", +} -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] +releases_github_path = "top-gg-community/python-sdk" source_suffix = ".rst" - -# The master toctree document. master_doc = "index" -# General information about the project. project = "topggpy" -copyright = "2021, Assanali Mukhanov" -author = "Assanali Mukhanov" - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. - -# with open('../dbl/__init__.py') as f: -# version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE).group(1) -# The full version, including alpha/beta/rc tags. +copyright = "2021 Assanali Mukhanov; 2024 null8626" +author = "null8626" release = version -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path +language = "en" exclude_patterns = ["_build"] -# -- Options for HTML output ---------------------------------------------- - -html_theme_options = {"navigation_depth": 2} -html_theme_path = [alabaster.get_path()] -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = "insegel" - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. +html_js_files = ["script.js"] +html_css_files = [ + "style.css", + "https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Roboto+Mono&display=swap", +] +html_favicon = "_static/favicon.ico" +html_theme = "furo" html_logo = "topgg.svg" - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -# html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -# html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -# html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -# html_additional_pages = {} - -# If false, no module index is generated. -# html_domain_indices = True - -# If false, no index is generated. -# html_use_index = True - -# If true, the index is split into individual pages for each letter. -# html_split_index = False - -# If true, links to the reST sources are added to the pages. -# html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. html_show_sphinx = False - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -# html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -# html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' -# html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# Now only 'ja' uses this config value -# html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -# html_search_scorer = 'scorer.js' - - -# -- Options for HTMLHelp output ------------------------------------------ - -# Output file base name for HTML help builder. htmlhelp_basename = "topggpydoc" -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, "topggpy.tex", "topggpy Documentation", "Assanali Mukhanov", "manual"), ] -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). man_pages = [(master_doc, "topggpy", "topggpy Documentation", [author], 1)] -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) texinfo_documents = [ ( master_doc, diff --git a/docs/examples/discord_py.rst b/docs/examples/discord_py.rst new file mode 100644 index 0000000..e77808d --- /dev/null +++ b/docs/examples/discord_py.rst @@ -0,0 +1,5 @@ +================== +Discord.py example +================== + +You should be redirected in a few moments. Otherwise, click here: https://github.com/Top-gg-Community/python-sdk/tree/master/examples/discordpy_example \ No newline at end of file diff --git a/docs/examples/hikari.rst b/docs/examples/hikari.rst new file mode 100644 index 0000000..89fc651 --- /dev/null +++ b/docs/examples/hikari.rst @@ -0,0 +1,5 @@ +============== +Hikari example +============== + +You should be redirected in a few moments. Otherwise, click here: https://github.com/Top-gg-Community/python-sdk/tree/master/examples/hikari_example \ No newline at end of file diff --git a/docs/examples/index.rst b/docs/examples/index.rst new file mode 100644 index 0000000..2057326 --- /dev/null +++ b/docs/examples/index.rst @@ -0,0 +1,9 @@ +######## +Examples +######## + + .. toctree:: + :maxdepth: 2 + + discord_py + hikari \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index a634d38..f2aebe2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,19 +3,51 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -################################### -Welcome to topggpy's documentation! -################################### +##################### +Top.gg Python Library +##################### -.. toctree:: - :maxdepth: 1 +.. image:: https://img.shields.io/pypi/v/topggpy.svg + :target: https://pypi.python.org/pypi/topggpy + :alt: View on PyPI +.. image:: https://img.shields.io/pypi/dm/topggpy?style=flat-square + :target: https://topggpy.readthedocs.io/en/latest/?badge=latest + :alt: Monthly PyPI downloads - api - whats_new +A simple API wrapper for `Top.gg `_ written in Python. + +Installation +------------ + +.. code:: bash + + pip install topggpy + +Features +-------- -Indices and tables -================== +* POST server count +* GET bot info, server count, upvote info +* GET user info +* GET widgets (large and small) including custom ones. See `docs.top.gg `_ for more info. +* GET weekend status +* Built-in webhook to handle Top.gg votes +* Automated server count posting +* Searching for bots via the API -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` +Additional information +---------------------- + +* Before using the webhook provided by this library, make sure that you have specified port open. +* Optimal values for port are between 1024 and 49151. +* If you happen to need help implementing topggpy in your bot, feel free to ask in the ``#development`` or ``#api`` channels in our `Discord server `_. + +.. toctree:: + :maxdepth: 2 + :hidden: + + api/index.rst + examples/index.rst + whats_new + repository + support \ No newline at end of file diff --git a/docs/repository.rst b/docs/repository.rst new file mode 100644 index 0000000..a542ad6 --- /dev/null +++ b/docs/repository.rst @@ -0,0 +1,5 @@ +================= +GitHub repository +================= + +You should be redirected in a few moments. Otherwise, click here: https://github.com/top-gg-community/python-sdk \ No newline at end of file diff --git a/docs/support.rst b/docs/support.rst new file mode 100644 index 0000000..531270f --- /dev/null +++ b/docs/support.rst @@ -0,0 +1,5 @@ +============== +Support server +============== + +You should be redirected in a few moments. Otherwise, click here: https://discord.gg/dbl \ No newline at end of file diff --git a/docs/topgg.svg b/docs/topgg.svg index 9afe235..63f5812 100644 --- a/docs/topgg.svg +++ b/docs/topgg.svg @@ -1,10 +1,4 @@ - - - - - + + + diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 8fc1d0e..d506500 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -1,13 +1,22 @@ -.. currentmodule:: topgg - -.. _whats_new: - ########## -What's New +What's new ########## This page keeps a detailed human friendly rendering of what's new and changed in specific versions. +v2.0.1 +====== +* Added Python 3.12 support (:issue:`78`) +* Dropped Python 3.6 and 3.7 support (:issue:`75`) +* Removed the need to manually set a ``default_bot_id`` property +* :attr:`~.BotData.def_avatar` is now an optional string +* :meth:`~.DBLClient.get_bots` is now deprecated +* :meth:`~.DBLClient.get_guild_count` no longer accepts a ``bot_id`` argument +* :meth:`~.DBLClient.get_bot_votes` no longer raises a :class:`~.ClientException` without a ``default_bot_id`` property +* :meth:`~.DBLClient.get_bot_info` no longer raises a :class:`~.ClientException` without a ``default_bot_id`` property +* :meth:`~.DBLClient.generate_widget` no longer raises a :class:`~.ClientException` without a ``default_bot_id`` property +* Documentation overhaul + v2.0.0a ======= * :obj:`~.DBLClient` now doesn't take in ``discord.Client`` instance @@ -19,15 +28,15 @@ v2.0.0a v1.4.0 ====== -* The type of data passed to ``on_dbl_vote`` has been changed from :class:`dict` to :obj:`BotVoteData` -* The type of data passed to ``on_dsl_vote`` has been changed from :class:`dict` to :obj:`ServerVoteData` +* The type of data passed to ``on_dbl_vote`` has been changed from :class:`~.dict` to :obj:`BotVoteData` +* The type of data passed to ``on_dsl_vote`` has been changed from :class:`~.dict` to :obj:`ServerVoteData` v1.3.0 ====== * Introduced `global ratelimiter `__ to follow Top.gg global ratelimits - * Fixed an :exc:`AttributeError` raised by :meth:`HTTPClient.request` + * Fixed an :exc:`AttributeError` raised by :meth:`~.HTTPClient.request` * `Resource-specific ratelimit `__ is now actually resource-specific @@ -35,41 +44,41 @@ v1.2.0 ====== * Introduced global ratelimiter along with bot endpoints ratelimiter -* Follow consistency with typing in :class:`HTTPClient` and :class:`DBLClient` along with updated docstrings (:issue:`55`) +* Follow consistency with typing in :class:`~.HTTPClient` and :class:`~.DBLClient` along with updated docstrings (:issue:`55`) v1.1.0 ====== * Introduced `data models `__ - * :meth:`DBLClient.get_bot_votes` now returns a list of :class:`BriefUserData` objects + * :meth:`~.DBLClient.get_bot_votes` now returns a list of :class:`~.BriefUserData` objects - * :meth:`DBLClient.get_bot_info` now returns a :class:`BotData` object + * :meth:`~.DBLClient.get_bot_info` now returns a :class:`~.BotData` object - * :meth:`DBLClient.get_guild_count` now returns a :class:`BotStatsData` object + * :meth:`~.DBLClient.get_guild_count` now returns a :class:`~.BotStatsData` object - * :meth:`DBLClient.get_user_info` now returns a :class:`UserData` object + * :meth:`~.DBLClient.get_user_info` now returns a :class:`~.UserData` object -* :meth:`WebhookManager.run` now returns an :class:`asyncio.Task`, meaning it can now be optionally awaited +* :meth:`~.WebhookManager.run` now returns an :class:`~.asyncio.Task`, meaning it can now be optionally awaited v1.0.1 ====== -* :attr:`WebhookManager.webserver` now instead returns :class:`aiohttp.web.Application` for ease of use +* :attr:`~.WebhookManager.webserver` now instead returns :class:`~.aiohttp.web.Application` for ease of use v1.0.0 ====== * Renamed the module folder from ``dbl`` to ``topgg`` -* Added ``post_shard_count`` argument to :meth:`DBLClient.post_guild_count` +* Added ``post_shard_count`` argument to :meth:`~.DBLClient.post_guild_count` * Autopost now supports automatic shard posting (:issue:`42`) * Large webhook system rework, read the :obj:`api/webhook` section for more * Added support for server webhooks -* Renamed ``DBLException`` to :class:`TopGGException` -* Renamed ``DBLClient.get_bot_upvotes()`` to :meth:`DBLClient.get_bot_votes` -* Added :meth:`DBLClient.generate_widget` along with the ``widgets`` section in the documentation +* Renamed ``DBLException`` to :class:`~.TopGGException` +* Renamed ``DBLClient.get_bot_upvotes()`` to :meth:`~.DBLClient.get_bot_votes` +* Added :meth:`~.DBLClient.generate_widget` along with the ``widgets`` section in the documentation * Implemented a properly working ratelimiter * Added :func:`on_autopost_error` * All autopost events now follow ``on_autopost_x`` naming format, e.g. :func:`on_autopost_error`, :func:`on_autopost_success` @@ -78,7 +87,7 @@ v1.0.0 v0.4.0 ====== -* :meth:`DBLClient.post_guild_count` now supports a custom ``guild_count`` argument, which accepts either an integer or list of integers +* :meth:`~.DBLClient.post_guild_count` now supports a custom ``guild_count`` argument, which accepts either an integer or list of integers * Reworked how shard info is posted * Removed ``InvalidArgument`` and ``ConnectionClosed`` exceptions * Added ``ServerError`` exception @@ -87,12 +96,12 @@ v0.3.3 ====== * Internal changes regarding support of Top.gg migration -* Fixed errors raised when using :meth:`DBLClient.close` without built-in webhook +* Fixed errors raised when using :meth:`~.DBLClient.close` without built-in webhook v0.3.2 ====== -* ``Client`` class has been renamed to ``DBLClient`` +* ``Client`` class has been renamed to :class:`~.DBLClient` v0.3.1 ====== @@ -104,7 +113,7 @@ v0.3.1 v0.3.0 ====== -* :class:`DBLClient` now has ``autopost`` kwarg that will post server count automatically every 30 minutes +* :class:`~.DBLClient` now has ``autopost`` kwarg that will post server count automatically every 30 minutes * Fixed code 403 errors * Added ``on_dbl_vote``, an event that is called when you test your webhook * Added ``on_dbl_test``, an event that is called when someone tests your webhook @@ -114,7 +123,7 @@ v0.2.1 * Added webhook * Removed support for discord.py versions lower than 1.0.0 -* Made :meth:`DBLClient.get_weekend_status` return a boolean value +* Made :meth:`~.DBLClient.get_weekend_status` return a boolean value * Added webhook example in README * Removed ``post_server_count`` and ``get_server_count`` @@ -129,9 +138,9 @@ v0.2.0 * Made ``get_server_count`` an alias for ``get_guild_count`` -* Added :meth:`DBLClient.get_weekend_status` -* Removed all parameters from :meth:`DBLClient.get_upvote_info` -* Added limit to :meth:`DBLClient.get_bots` +* Added :meth:`~.DBLClient.get_weekend_status` +* Removed all parameters from :meth:`~.DBLClient.get_upvote_info` +* Added limit to :meth:`~.DBLClient.get_bots` * Fixed example in README v0.1.6 diff --git a/examples/discordpy_example/__main__.py b/examples/discordpy_example/__main__.py index f1c1f6d..7077170 100644 --- a/examples/discordpy_example/__main__.py +++ b/examples/discordpy_example/__main__.py @@ -1,6 +1,7 @@ # The MIT License (MIT) # Copyright (c) 2021 Norizon +# Copyright (c) 2024 null8626 # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), @@ -39,7 +40,6 @@ @client.event async def on_ready(): assert client.user is not None - dblclient.default_bot_id = client.user.id # if it's ready, then the event loop's run, # hence it's safe starting the autopost here diff --git a/examples/hikari_example/__main__.py b/examples/hikari_example/__main__.py index 0bef502..82da8ba 100644 --- a/examples/hikari_example/__main__.py +++ b/examples/hikari_example/__main__.py @@ -1,6 +1,7 @@ # The MIT License (MIT) # Copyright (c) 2021 Norizon +# Copyright (c) 2024 null8626 # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), @@ -40,7 +41,6 @@ async def on_started(event: hikari.StartedEvent): me: hikari.OwnUser = event.app.get_me() assert me is not None - dblclient.default_bot_id = me.id # since StartedEvent is a lifetime event # this event will only get dispatched once diff --git a/examples/hikari_example/callbacks/autopost.py b/examples/hikari_example/callbacks/autopost.py index 3ac467b..6bab89f 100644 --- a/examples/hikari_example/callbacks/autopost.py +++ b/examples/hikari_example/callbacks/autopost.py @@ -29,6 +29,7 @@ _LOGGER = logging.getLogger("callbacks.autopost") + # these functions can be async too! def on_autopost_success( # uncomment this if you want to get access to app @@ -56,6 +57,4 @@ def on_autopost_error( def stats(app: hikari.GatewayBot = topgg.data(hikari.GatewayBot)): - return topgg.StatsWrapper( - guild_count=len(app.cache.get_guilds_view()), shard_count=app.shard_count - ) + return topgg.StatsWrapper(guild_count=len(app.cache.get_guilds_view()), shard_count=app.shard_count) diff --git a/examples/hikari_example/callbacks/webhook.py b/examples/hikari_example/callbacks/webhook.py index 50c53a7..49daeda 100644 --- a/examples/hikari_example/callbacks/webhook.py +++ b/examples/hikari_example/callbacks/webhook.py @@ -31,6 +31,7 @@ _LOGGER = logging.getLogger("callbacks.webhook") + # this can be async too! @topgg.endpoint("/dblwebhook", topgg.WebhookType.BOT, "youshallnotpass") async def endpoint( diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..80a0f4d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools"] + +[project] +name = "topggpy" +version = "2.0.1" +description = "A simple API wrapper for Top.gg written in Python." +readme = "README.rst" +license = { text = "MIT" } +authors = [{ name = "null8626" }, { name = "Top.gg" }] +keywords = ["discord", "bot", "topgg", "top.gg"] +dependencies = ["aiohttp>=3.9.5"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Utilities" +] +requires-python = ">=3.8" + +[project.urls] +Documentation = "https://topggpy.readthedocs.io/en/latest/" +"Release notes" = "https://topggpy.readthedocs.io/en/latest/whats_new.html" +Repository = "https://github.com/top-gg-community/python-sdk" +"Support server" = "https://discord.gg/dbl" \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index e5d5d95..36ad305 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,13 +1,9 @@ -# Formatting -git+https://github.com/timothycrosley/isort -git+https://github.com/psf/black - -# Unit Testing -mock -pytest -pytest-asyncio -pytest-mock -pytest-cov - -# Linting -flake8 +# Formatting and Linting +ruff + +# Unit Testing +mock +pytest +pytest-asyncio +pytest-mock +pytest-cov \ No newline at end of file diff --git a/requirements-docs.txt b/requirements-docs.txt index e75c15e..8315d05 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,2 +1,3 @@ +furo sphinx -insegel +sphinx-reredirects \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9ad0580..86edf4d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -aiohttp>=3.6.0,<3.9.0 \ No newline at end of file +aiohttp>=3.9.5 \ No newline at end of file diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..650555f --- /dev/null +++ b/ruff.toml @@ -0,0 +1,9 @@ +line-length = 120 + +[format] +docstring-code-format = true +docstring-code-line-length = 120 +line-ending = "lf" + +[lint] +ignore = ["E722", "F401", "F403"] \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 36733af..0000000 --- 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/tests/test_autopost.py b/tests/test_autopost.py index a4f8ee7..115297c 100644 --- a/tests/test_autopost.py +++ b/tests/test_autopost.py @@ -1,92 +1,87 @@ -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 +import datetime + +import mock +import pytest +from aiohttp import ClientSession +from pytest_mock import MockerFixture + +from topgg import DBLClient +from topgg.autopost import AutoPoster +from topgg.errors import ServerError, TopGGException, Unauthorized + + +MOCK_TOKEN = "amogus.eyJpZCI6IjEwMjY1MjU1NjgzNDQyNjQ3MjQiLCJib3QiOnRydWUsImlhdCI6MTY5OTk4NDYyM30.amogus" + + +@pytest.fixture +def session() -> ClientSession: + return mock.Mock(ClientSession) + + +@pytest.fixture +def autopost(session: ClientSession) -> AutoPoster: + return AutoPoster(DBLClient(MOCK_TOKEN, session=session)) + + +@pytest.mark.asyncio +async def test_AutoPoster_breaks_autopost_loop_on_401(mocker: MockerFixture, session: ClientSession) -> None: + response = mock.Mock("reason, status") + response.reason = "Unauthorized" + response.status = 401 + + mocker.patch("topgg.DBLClient.post_guild_count", side_effect=Unauthorized(response, {})) + + callback = mock.Mock() + autopost = DBLClient(MOCK_TOKEN, session=session).autopost().stats(callback) + assert isinstance(autopost, AutoPoster) + assert not isinstance(autopost.stats()(callback), AutoPoster) + + with pytest.raises(Unauthorized): + await autopost.start() + + callback.assert_called_once() + assert not autopost.is_running + + +@pytest.mark.asyncio +async def test_AutoPoster_raises_missing_stats(autopost: AutoPoster) -> None: + with pytest.raises(TopGGException, match="you must provide a callback that returns the stats."): + await autopost.start() + + +@pytest.mark.asyncio +async def test_AutoPoster_raises_already_running(autopost: AutoPoster) -> None: + autopost.stats(mock.Mock()).start() + with pytest.raises(TopGGException, match="the autopost is already running."): + await autopost.start() + + +@pytest.mark.asyncio +async def test_AutoPoster_interval_too_short(autopost: AutoPoster) -> None: + with pytest.raises(ValueError, match="interval must be greated than 900 seconds."): + autopost.set_interval(50) + + +@pytest.mark.asyncio +async def test_AutoPoster_error_callback(mocker: MockerFixture, autopost: AutoPoster) -> None: + error_callback = mock.Mock() + response = mock.Mock("reason, status") + response.reason = "Internal Server Error" + response.status = 500 + side_effect = ServerError(response, {}) + + mocker.patch("topgg.DBLClient.post_guild_count", side_effect=side_effect) + task = autopost.on_error(error_callback).stats(mock.Mock()).start() + autopost.stop() + await task + error_callback.assert_called_once_with(side_effect) + + +def test_AutoPoster_interval(autopost: AutoPoster): + assert autopost.interval == 900 + autopost.set_interval(datetime.timedelta(hours=1)) + assert autopost.interval == 3600 + autopost.interval = datetime.timedelta(hours=2) + assert autopost.interval == 7200 + autopost.interval = 3600 + assert autopost.interval == 3600 diff --git a/tests/test_client.py b/tests/test_client.py index fb634ea..fa11c43 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,3 @@ -import typing as t - import mock import pytest from aiohttp import ClientSession @@ -8,6 +6,9 @@ from topgg import errors +MOCK_TOKEN = "amogus.eyJpZCI6IjEwMjY1MjU1NjgzNDQyNjQ3MjQiLCJib3QiOnRydWUsImlhdCI6MTY5OTk4NDYyM30.amogus" + + @pytest.fixture def session() -> ClientSession: return mock.Mock(ClientSession) @@ -15,14 +16,14 @@ def session() -> ClientSession: @pytest.fixture def client() -> topgg.DBLClient: - client = topgg.DBLClient(token="TOKEN", default_bot_id=1234) + client = topgg.DBLClient(token=MOCK_TOKEN) client.http = mock.Mock(topgg.http.HTTPClient) return client @pytest.mark.asyncio async def test_HTTPClient_with_external_session(session: ClientSession): - http = topgg.http.HTTPClient("TOKEN", session=session) + http = topgg.http.HTTPClient(MOCK_TOKEN, session=session) assert not http._own_session await http.close() session.close.assert_not_called() @@ -30,57 +31,23 @@ async def test_HTTPClient_with_external_session(session: ClientSession): @pytest.mark.asyncio async def test_HTTPClient_with_no_external_session(session: ClientSession): - http = topgg.http.HTTPClient("TOKEN") + http = topgg.http.HTTPClient(MOCK_TOKEN) http.session = session assert http._own_session await http.close() session.close.assert_called_once() -@pytest.mark.asyncio -async def test_DBLClient_get_bot_votes_with_no_default_bot_id(): - client = topgg.DBLClient("TOKEN") - with pytest.raises( - errors.ClientException, - match="you must set default_bot_id when constructing the client.", - ): - await client.get_bot_votes() - - @pytest.mark.asyncio async def test_DBLClient_post_guild_count_with_no_args(): - client = topgg.DBLClient("TOKEN", default_bot_id=1234) + client = topgg.DBLClient(MOCK_TOKEN) with pytest.raises(TypeError, match="stats or guild_count must be provided."): await client.post_guild_count() -@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") + client = topgg.DBLClient(MOCK_TOKEN) assert not client.is_closed await client.close() assert client.is_closed @@ -88,6 +55,15 @@ async def test_closed_DBLClient_raises_exception(): await client.get_weekend_status() +@pytest.mark.asyncio +async def test_DBLClient_bot_id(): + client = topgg.DBLClient(MOCK_TOKEN) + assert not client.is_closed + assert client.bot_id == 1026525568344264724 + await client.close() + assert client.is_closed + + @pytest.mark.asyncio async def test_DBLClient_get_weekend_status(client: topgg.DBLClient): client.http.get_weekend_status = mock.AsyncMock() @@ -116,13 +92,6 @@ async def test_DBLClient_get_bot_votes(client: topgg.DBLClient): client.http.get_bot_votes.assert_called_once() -@pytest.mark.asyncio -async def test_DBLClient_get_bots(client: topgg.DBLClient): - client.http.get_bots = mock.AsyncMock(return_value={"results": []}) - await client.get_bots() - client.http.get_bots.assert_called_once() - - @pytest.mark.asyncio async def test_DBLClient_get_user_info(client: topgg.DBLClient): client.http.get_user_info = mock.AsyncMock(return_value={}) diff --git a/tests/test_data_container.py b/tests/test_data_container.py index 978574f..f89466e 100644 --- a/tests/test_data_container.py +++ b/tests/test_data_container.py @@ -13,20 +13,13 @@ def data_container() -> DataContainerMixin: return dc -async def _async_callback( - text: str = data(str), number: int = data(int), mapping: dict = data(dict) -): - ... +async def _async_callback(text: str = data(str), number: int = data(int), mapping: dict = data(dict)): ... -def _sync_callback( - text: str = data(str), number: int = data(int), mapping: dict = data(dict) -): - ... +def _sync_callback(text: str = data(str), number: int = data(int), mapping: dict = data(dict)): ... -def _invalid_callback(number: float = data(float)): - ... +def _invalid_callback(number: float = data(float)): ... @pytest.mark.asyncio @@ -42,8 +35,7 @@ async def test_data_container_invoke_sync_callback(data_container: DataContainer def test_data_container_raises_data_already_exists(data_container: DataContainerMixin): with pytest.raises( TopGGException, - match=" already exists. If you wish to override it, " - "pass True into the override parameter.", + match=" already exists. If you wish to override it, " "pass True into the override parameter.", ): data_container.set_data("TEST") diff --git a/tests/test_ratelimiter.py b/tests/test_ratelimiter.py index f1fbed6..9153b3a 100644 --- a/tests/test_ratelimiter.py +++ b/tests/test_ratelimiter.py @@ -1,28 +1,28 @@ -import pytest - -from topgg.ratelimiter import AsyncRateLimiter - -n = period = 10 - - -@pytest.fixture -def limiter() -> AsyncRateLimiter: - return AsyncRateLimiter(max_calls=n, period=period) - - -@pytest.mark.asyncio -async def test_AsyncRateLimiter_calls(limiter: AsyncRateLimiter) -> None: - for _ in range(n): - async with limiter: - pass - - assert len(limiter.calls) == limiter.max_calls == n - - -@pytest.mark.asyncio -async def test_AsyncRateLimiter_timespan_property(limiter: AsyncRateLimiter) -> None: - for _ in range(n): - async with limiter: - pass - - assert limiter._timespan < period +import pytest + +from topgg.ratelimiter import AsyncRateLimiter + +n = period = 10 + + +@pytest.fixture +def limiter() -> AsyncRateLimiter: + return AsyncRateLimiter(max_calls=n, period=period) + + +@pytest.mark.asyncio +async def test_AsyncRateLimiter_calls(limiter: AsyncRateLimiter) -> None: + for _ in range(n): + async with limiter: + pass + + assert len(limiter.calls) == limiter.max_calls == n + + +@pytest.mark.asyncio +async def test_AsyncRateLimiter_timespan_property(limiter: AsyncRateLimiter) -> None: + for _ in range(n): + async with limiter: + pass + + assert limiter._timespan < period diff --git a/tests/test_type.py b/tests/test_type.py index caec363..8cea66c 100644 --- a/tests/test_type.py +++ b/tests/test_type.py @@ -143,12 +143,7 @@ def test_widget_options_fields(widget_options: types.WidgetOptions) -> None: for attr in widget_options: if "id" in attr.lower(): assert isinstance(widget_options[attr], int) or widget_options[attr] is None - assert ( - widget_options.get(attr) - == widget_options[attr] - == widget_options[attr] - == getattr(widget_options, attr) - ) + assert widget_options.get(attr) == widget_options[attr] == widget_options[attr] == getattr(widget_options, attr) def test_vote_data_fields(vote_data: types.VoteDataDict) -> None: @@ -165,11 +160,7 @@ def test_bot_vote_data_fields(bot_vote_data: types.BotVoteData) -> None: assert isinstance(bot_vote_data["bot"], int) for attr in bot_vote_data: - assert ( - getattr(bot_vote_data, attr) - == bot_vote_data.get(attr) - == bot_vote_data[attr] - ) + assert getattr(bot_vote_data, attr) == bot_vote_data.get(attr) == bot_vote_data[attr] def test_server_vote_data_fields(server_vote_data: types.BotVoteData) -> None: @@ -178,11 +169,7 @@ def test_server_vote_data_fields(server_vote_data: types.BotVoteData) -> None: assert isinstance(server_vote_data["guild"], int) for attr in server_vote_data: - assert ( - getattr(server_vote_data, attr) - == server_vote_data.get(attr) - == server_vote_data[attr] - ) + assert getattr(server_vote_data, attr) == server_vote_data.get(attr) == server_vote_data[attr] def test_bot_stats_data_attrs(bot_stats_data: types.BotStatsData) -> None: diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 8ef3c71..db1da09 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -1,80 +1,76 @@ -import typing as t - -import aiohttp -import mock -import pytest - -from topgg import WebhookManager, WebhookType -from topgg.errors import TopGGException - -auth = "youshallnotpass" - - -@pytest.fixture -def webhook_manager() -> WebhookManager: - return ( - WebhookManager() - .endpoint() - .type(WebhookType.BOT) - .auth(auth) - .route("/dbl") - .callback(print) - .add_to_manager() - .endpoint() - .type(WebhookType.GUILD) - .auth(auth) - .route("/dsl") - .callback(print) - .add_to_manager() - ) - - -def test_WebhookManager_routes(webhook_manager: WebhookManager) -> None: - assert len(webhook_manager.app.router.routes()) == 2 - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "headers, result, state", - [({"authorization": auth}, 200, True), ({}, 401, False)], -) -async def test_WebhookManager_validates_auth( - webhook_manager: WebhookManager, headers: t.Dict[str, str], result: int, state: bool -) -> None: - await webhook_manager.start(5000) - - try: - for path in ("dbl", "dsl"): - async with aiohttp.request( - "POST", f"http://localhost:5000/{path}", headers=headers, json={} - ) as r: - assert r.status == result - finally: - await webhook_manager.close() - assert not webhook_manager.is_running - - -def test_WebhookEndpoint_callback_unset(webhook_manager: WebhookManager): - with pytest.raises( - TopGGException, - match="endpoint missing callback.", - ): - webhook_manager.endpoint().add_to_manager() - - -def test_WebhookEndpoint_route_unset(webhook_manager: WebhookManager): - with pytest.raises( - TopGGException, - match="endpoint missing type.", - ): - webhook_manager.endpoint().callback(mock.Mock()).add_to_manager() - - -def test_WebhookEndpoint_type_unset(webhook_manager: WebhookManager): - with pytest.raises( - TopGGException, - match="endpoint missing route.", - ): - webhook_manager.endpoint().callback(mock.Mock()).type( - WebhookType.BOT - ).add_to_manager() +import typing as t + +import aiohttp +import mock +import pytest + +from topgg import WebhookManager, WebhookType +from topgg.errors import TopGGException + +auth = "youshallnotpass" + + +@pytest.fixture +def webhook_manager() -> WebhookManager: + return ( + WebhookManager() + .endpoint() + .type(WebhookType.BOT) + .auth(auth) + .route("/dbl") + .callback(print) + .add_to_manager() + .endpoint() + .type(WebhookType.GUILD) + .auth(auth) + .route("/dsl") + .callback(print) + .add_to_manager() + ) + + +def test_WebhookManager_routes(webhook_manager: WebhookManager) -> None: + assert len(webhook_manager.app.router.routes()) == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "headers, result, state", + [({"authorization": auth}, 200, True), ({}, 401, False)], +) +async def test_WebhookManager_validates_auth( + webhook_manager: WebhookManager, headers: t.Dict[str, str], result: int, state: bool +) -> None: + await webhook_manager.start(5000) + + try: + for path in ("dbl", "dsl"): + async with aiohttp.request("POST", f"http://localhost:5000/{path}", headers=headers, json={}) as r: + assert r.status == result + finally: + await webhook_manager.close() + assert not webhook_manager.is_running + + +def test_WebhookEndpoint_callback_unset(webhook_manager: WebhookManager): + with pytest.raises( + TopGGException, + match="endpoint missing callback.", + ): + webhook_manager.endpoint().add_to_manager() + + +def test_WebhookEndpoint_route_unset(webhook_manager: WebhookManager): + with pytest.raises( + TopGGException, + match="endpoint missing type.", + ): + webhook_manager.endpoint().callback(mock.Mock()).add_to_manager() + + +def test_WebhookEndpoint_type_unset(webhook_manager: WebhookManager): + with pytest.raises( + TopGGException, + match="endpoint missing route.", + ): + webhook_manager.endpoint().callback(mock.Mock()).type(WebhookType.BOT).add_to_manager() diff --git a/topgg/__init__.py b/topgg/__init__.py index 1a9025e..3c5f845 100644 --- a/topgg/__init__.py +++ b/topgg/__init__.py @@ -5,14 +5,14 @@ ~~~~~~~~~~~~~~~~~~~~~~~~~ A basic wrapper for the Top.gg API. :copyright: (c) 2021 Assanali Mukhanov & Top.gg +:copyright: (c) 2024 null8626 & Top.gg :license: MIT, see LICENSE for more details. """ __title__ = "topggpy" -__author__ = "Assanali Mukhanov" -__maintainer__ = "Norizon" +__author__ = "null8626" __license__ = "MIT" -__version__ = "2.0.0a1" +__version__ = "2.0.1" from .autopost import * from .client import * diff --git a/topgg/autopost.py b/topgg/autopost.py index 3bfe4af..7025b9b 100644 --- a/topgg/autopost.py +++ b/topgg/autopost.py @@ -78,17 +78,13 @@ def __init__(self, client: "DBLClient") -> None: def _default_error_handler(self, exception: Exception) -> None: print("Ignoring exception in auto post loop:", file=sys.stderr) - traceback.print_exception( - type(exception), exception, exception.__traceback__, file=sys.stderr - ) + traceback.print_exception(type(exception), exception, exception.__traceback__, file=sys.stderr) @t.overload - def on_success(self, callback: None) -> t.Callable[[CallbackT], CallbackT]: - ... + def on_success(self, callback: None) -> t.Callable[[CallbackT], CallbackT]: ... @t.overload - def on_success(self, callback: CallbackT) -> "AutoPoster": - ... + def on_success(self, callback: CallbackT) -> "AutoPoster": ... def on_success(self, callback: t.Any = None) -> t.Any: """ @@ -103,15 +99,15 @@ def on_success(self, callback: t.Any = None) -> t.Any: # The following are valid. autopost = dblclient.autopost().on_success(lambda: print("Success!")) + # Used as decorator, the decorated function will become the AutoPoster object. @autopost.on_success - def autopost(): - ... + def autopost(): ... + # Used as decorator factory, the decorated function will still be the function itself. @autopost.on_success() - def on_success(): - ... + def on_success(): ... """ if callback is not None: self._success = callback @@ -124,12 +120,10 @@ def decorator(callback: CallbackT) -> CallbackT: return decorator @t.overload - def on_error(self, callback: None) -> t.Callable[[CallbackT], CallbackT]: - ... + def on_error(self, callback: None) -> t.Callable[[CallbackT], CallbackT]: ... @t.overload - def on_error(self, callback: CallbackT) -> "AutoPoster": - ... + def on_error(self, callback: CallbackT) -> "AutoPoster": ... def on_error(self, callback: t.Any = None) -> t.Any: """ @@ -148,15 +142,15 @@ def on_error(self, callback: t.Any = None) -> t.Any: # The following are valid. autopost = dblclient.autopost().on_error(lambda exc: print("Failed posting stats!", exc)) + # Used as decorator, the decorated function will become the AutoPoster object. @autopost.on_error - def autopost(exc: Exception): - ... + def autopost(exc: Exception): ... + # Used as decorator factory, the decorated function will still be the function itself. @autopost.on_error() - def on_error(exc: Exception): - ... + def on_error(exc: Exception): ... """ if callback is not None: self._error = callback @@ -169,12 +163,10 @@ def decorator(callback: CallbackT) -> CallbackT: return decorator @t.overload - def stats(self, callback: None) -> t.Callable[[StatsCallbackT], StatsCallbackT]: - ... + def stats(self, callback: None) -> t.Callable[[StatsCallbackT], StatsCallbackT]: ... @t.overload - def stats(self, callback: StatsCallbackT) -> "AutoPoster": - ... + def stats(self, callback: StatsCallbackT) -> "AutoPoster": ... def stats(self, callback: t.Any = None) -> t.Any: """ @@ -192,16 +184,14 @@ def stats(self, callback: t.Any = None) -> t.Any: # In this example, we fetch the stats from a Discord client instance. client = Client(...) dblclient = topgg.DBLClient(TOKEN).set_data(client) - autopost = ( - dblclient - .autopost() - .on_success(lambda: print("Successfully posted the stats!") - ) + autopost = dblclient.autopost().on_success(lambda: print("Successfully posted the stats!")) + @autopost.stats() def get_stats(client: Client = topgg.data(Client)): return topgg.StatsWrapper(guild_count=len(client.guilds), shard_count=len(client.shards)) + # somewhere after the event loop has started autopost.start() """ @@ -230,11 +220,11 @@ def set_interval(self, seconds: t.Union[float, datetime.timedelta]) -> "AutoPost Sets the interval between posting stats. Args: - seconds (:obj:`typing.Union` [ :obj:`float`, :obj:`datetime.timedelta` ]) + seconds (Union[:obj:`float`, :obj:`datetime.timedelta`]) The interval. Raises: - :obj:`ValueError` + ValueError If the provided interval is less than 900 seconds. """ if isinstance(seconds, datetime.timedelta): @@ -291,13 +281,11 @@ def start(self) -> "asyncio.Task[None]": This method must be called when the event loop has already running! Raises: - :obj:`~.errors.TopGGException` + :exc:`~.errors.TopGGException` If there's no callback provided or the autopost is already running. """ if not hasattr(self, "_stats"): - raise errors.TopGGException( - "you must provide a callback that returns the stats." - ) + raise errors.TopGGException("you must provide a callback that returns the stats.") if self.is_running: raise errors.TopGGException("the autopost is already running.") diff --git a/topgg/client.py b/topgg/client.py index 0f1a72d..50e0fc4 100644 --- a/topgg/client.py +++ b/topgg/client.py @@ -3,6 +3,7 @@ # The MIT License (MIT) # Copyright (c) 2021 Assanali Mukhanov +# Copyright (c) 2024 null8626 # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), @@ -24,7 +25,10 @@ __all__ = ["DBLClient"] +import base64 +import json import typing as t +import warnings import aiohttp @@ -45,28 +49,33 @@ class DBLClient(DataContainerMixin): token (:obj:`str`): Your bot's Top.gg API Token. Keyword Args: - default_bot_id (:obj:`typing.Optional` [ :obj:`int` ]) - The default bot_id. You can override this by passing it when calling a method. session (:class:`aiohttp.ClientSession`) An `aiohttp session`_ to use for requests to the API. **kwargs: Arbitrary kwargs to be passed to :class:`aiohttp.ClientSession` if session was not provided. """ - __slots__ = ("http", "default_bot_id", "_token", "_is_closed", "_autopost") + __slots__ = ("http", "bot_id", "_token", "_is_closed", "_autopost") http: HTTPClient def __init__( self, token: str, *, - default_bot_id: t.Optional[int] = None, session: t.Optional[aiohttp.ClientSession] = None, **kwargs: t.Any, ) -> None: super().__init__() self._token = token - self.default_bot_id = default_bot_id + + try: + encoded_json = token.split(".")[1] + encoded_json += "=" * (4 - (len(encoded_json) % 4)) + + self.bot_id = int(json.loads(base64.b64decode(encoded_json))["id"]) + except: + raise errors.ClientException("invalid token.") + self._is_closed = False if session is not None: self.http = HTTPClient(token, session=session) @@ -83,13 +92,6 @@ async def _ensure_session(self) -> None: if not hasattr(self, "http"): self.http = HTTPClient(self._token, session=None) - def _validate_and_get_bot_id(self, bot_id: t.Optional[int]) -> int: - bot_id = bot_id or self.default_bot_id - if bot_id is None: - raise errors.ClientException("bot_id or default_bot_id is unset.") - - return bot_id - async def get_weekend_status(self) -> bool: """Gets weekend status from Top.gg. @@ -97,7 +99,7 @@ async def get_weekend_status(self) -> bool: :obj:`bool`: The boolean value of weekend status. Raises: - :obj:`~.errors.ClientStateException` + :exc:`~.errors.ClientStateException` If the client has been closed. """ await self._ensure_session() @@ -105,8 +107,7 @@ async def get_weekend_status(self) -> bool: return data["is_weekend"] @t.overload - async def post_guild_count(self, stats: types.StatsWrapper) -> None: - ... + async def post_guild_count(self, stats: types.StatsWrapper) -> None: ... @t.overload async def post_guild_count( @@ -115,8 +116,7 @@ async def post_guild_count( guild_count: t.Union[int, t.List[int]], shard_count: t.Optional[int] = None, shard_id: t.Optional[int] = None, - ) -> None: - ... + ) -> None: ... async def post_guild_count( self, @@ -138,18 +138,18 @@ async def post_guild_count( An instance of StatsWrapper containing guild_count, shard_count, and shard_id. Keyword Arguments: - guild_count (:obj:`typing.Optional` [:obj:`typing.Union` [ :obj:`int`, :obj:`list` [ :obj:`int` ]]]) + guild_count (Optional[Union[:obj:`int`, List[:obj:`int`]]]) Number of guilds the bot is in. Applies the number to a shard instead if shards are specified. If not specified, length of provided client's property `.guilds` will be posted. - shard_count (:obj:`.typing.Optional` [ :obj:`int` ]) + shard_count (Optional[:obj:`int`]) The total number of shards. - shard_id (:obj:`.typing.Optional` [ :obj:`int` ]) + shard_id (Optional[:obj:`int`]) The index of the current shard. Top.gg uses `0 based indexing`_ for shards. Raises: TypeError If no argument is provided. - :obj:`~.errors.ClientStateException` + :exc:`~.errors.ClientStateException` If the client has been closed. """ if stats: @@ -161,28 +161,19 @@ async def post_guild_count( await self._ensure_session() await self.http.post_guild_count(guild_count, shard_count, shard_id) - async def get_guild_count( - self, bot_id: t.Optional[int] = None - ) -> types.BotStatsData: - """Gets a bot's guild count and shard info from Top.gg. - - Args: - bot_id (int) - ID of the bot you want to look up. Defaults to the provided Client object. + async def get_guild_count(self) -> types.BotStatsData: + """Gets this bot's guild count and shard info from Top.gg. Returns: :obj:`~.types.BotStatsData`: The guild count and shards of a bot on Top.gg. Raises: - :obj:`~.errors.ClientException` - If neither bot_id or default_bot_id was set. - :obj:`~.errors.ClientStateException` + :exc:`~.errors.ClientStateException` If the client has been closed. """ - bot_id = self._validate_and_get_bot_id(bot_id) await self._ensure_session() - response = await self.http.get_guild_count(bot_id) + response = await self.http.get_guild_count(self.bot_id) return types.BotStatsData(**response) async def get_bot_votes(self) -> t.List[types.BriefUserData]: @@ -192,21 +183,15 @@ async def get_bot_votes(self) -> t.List[types.BriefUserData]: This API endpoint is only available to the bot's owner. Returns: - :obj:`list` [ :obj:`~.types.BriefUserData` ]: + List[:obj:`~.types.BriefUserData`]: Users who voted for your bot. Raises: - :obj:`~.errors.ClientException` - If default_bot_id isn't provided when constructing the client. - :obj:`~.errors.ClientStateException` + :exc:`~.errors.ClientStateException` If the client has been closed. """ - if not self.default_bot_id: - raise errors.ClientException( - "you must set default_bot_id when constructing the client." - ) await self._ensure_session() - response = await self.http.get_bot_votes(self.default_bot_id) + response = await self.http.get_bot_votes(self.bot_id) return [types.BriefUserData(**user) for user in response] async def get_bot_info(self, bot_id: t.Optional[int] = None) -> types.BotData: @@ -216,7 +201,7 @@ async def get_bot_info(self, bot_id: t.Optional[int] = None) -> types.BotData: Args: bot_id (int) - ID of the bot to look up. Defaults to the provided Client object. + ID of the bot to look up. Defaults to this bot's ID. Returns: :obj:`~.types.BotData`: @@ -224,14 +209,11 @@ async def get_bot_info(self, bot_id: t.Optional[int] = None) -> types.BotData: `here `_. Raises: - :obj:`~.errors.ClientException` - If neither bot_id or default_bot_id was set. - :obj:`~.errors.ClientStateException` + :exc:`~.errors.ClientStateException` If the client has been closed. """ - bot_id = self._validate_and_get_bot_id(bot_id) await self._ensure_session() - response = await self.http.get_bot_info(bot_id) + response = await self.http.get_bot_info(bot_id or self.bot_id) return types.BotData(**response) async def get_bots( @@ -242,38 +224,19 @@ async def get_bots( search: t.Optional[t.Dict[str, t.Any]] = None, fields: t.Optional[t.List[str]] = None, ) -> types.DataDict[str, t.Any]: - """This function is a coroutine. - - Gets information about listed bots on Top.gg. - - Args: - limit (int) - The number of results to look up. Defaults to 50. Max 500 allowed. - offset (int) - The amount of bots to skip. Defaults to 0. - sort (str) - The field to sort by. Prefix with ``-`` to reverse the order. - search (:obj:`dict` [ :obj:`str`, :obj:`typing.Any` ]) - The search data. - fields (:obj:`list` [ :obj:`str` ]) - Fields to output. + """ + Warning: + This function is deprecated. + """ - Returns: - :obj:`~.types.DataDict`: - Info on bots that match the search query on Top.gg. + warnings.warn("get_bots is now deprecated.", DeprecationWarning) - 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"] - ] + 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: @@ -290,7 +253,7 @@ async def get_user_info(self, user_id: int) -> types.UserData: Information about a Top.gg user. Raises: - :obj:`~.errors.ClientStateException` + :exc:`~.errors.ClientStateException` If the client has been closed. """ await self._ensure_session() @@ -308,18 +271,11 @@ async def get_user_vote(self, user_id: int) -> bool: :obj:`bool`: Info about the user's vote. Raises: - :obj:`~.errors.ClientException` - If default_bot_id isn't provided when constructing the client. - :obj:`~.errors.ClientStateException` + :exc:`~.errors.ClientStateException` If the client has been closed. """ - if not self.default_bot_id: - raise errors.ClientException( - "you must set default_bot_id when constructing the client." - ) - await self._ensure_session() - data = await self.http.get_user_vote(self.default_bot_id, user_id) + data = await self.http.get_user_vote(self.bot_id, user_id) return bool(data["voted"]) def generate_widget(self, *, options: types.WidgetOptions) -> str: @@ -334,28 +290,22 @@ def generate_widget(self, *, options: types.WidgetOptions) -> str: str: Generated widget URL. Raises: - :obj:`~.errors.ClientException` - If bot_id or default_bot_id is unset. TypeError: If options passed is not of type WidgetOptions. """ if not isinstance(options, types.WidgetOptions): - raise TypeError( - "options argument passed to generate_widget must be of type WidgetOptions" - ) - - bot_id = options.id or self.default_bot_id - if bot_id is None: - raise errors.ClientException("bot_id or default_bot_id is unset.") + raise TypeError("options argument passed to generate_widget must be of type WidgetOptions") + bot_id = options.id or self.bot_id widget_query = f"noavatar={str(options.noavatar).lower()}" + for key, value in options.colors.items(): widget_query += f"&{key.lower()}{'' if key.lower().endswith('color') else 'color'}={value:x}" + widget_format = options.format widget_type = f"/{options.type}" if options.type else "" - url = f"""https://top.gg/api/widget{widget_type}/{bot_id}.{widget_format}?{widget_query}""" - return url + return f"https://top.gg/api/widget{widget_type}/{bot_id}.{widget_format}?{widget_query}" async def close(self) -> None: """Closes all connections.""" diff --git a/topgg/data.py b/topgg/data.py index 7126d3b..7d5f422 100644 --- a/topgg/data.py +++ b/topgg/data.py @@ -36,7 +36,7 @@ def data(type_: t.Type[T]) -> T: Represents the injected data. This should be set as the parameter's default value. Args: - `type_` (:obj:`type` [ :obj:`T` ]) + `type_` (:obj:`type` [ :obj:`T`]) The type of the injected data. Returns: @@ -52,6 +52,7 @@ def data(type_: t.Type[T]) -> T: dblclient = topgg.DBLClient(TOKEN).set_data(client) autopost: topgg.AutoPoster = dblclient.autopost() + @autopost.stats() def get_stats(client: Client = topgg.data(Client)): return topgg.StatsWrapper(guild_count=len(client.guilds), shard_count=len(client.shards)) @@ -79,20 +80,18 @@ class DataContainerMixin: def __init__(self) -> None: self._data: t.Dict[t.Type, t.Any] = {type(self): self} - def set_data( - self: DataContainerT, data_: t.Any, *, override: bool = False - ) -> DataContainerT: + def set_data(self: DataContainerT, data_: t.Any, *, override: bool = False) -> DataContainerT: """ Sets data to be available in your functions. Args: - `data_` (:obj:`typing.Any`) + `data_` (Any) The data to be injected. override (:obj:`bool`) Whether or not to override another instance that already exists. Raises: - :obj:`~.errors.TopGGException` + :exc:`~.errors.TopGGException` If override is False and another instance of the same type exists. """ type_ = type(data_) @@ -105,20 +104,16 @@ def set_data( return self @t.overload - def get_data(self, type_: t.Type[T]) -> t.Optional[T]: - ... + def get_data(self, type_: t.Type[T]) -> t.Optional[T]: ... @t.overload - def get_data(self, type_: t.Type[T], default: t.Any = None) -> t.Any: - ... + def get_data(self, type_: t.Type[T], default: t.Any = None) -> t.Any: ... def get_data(self, type_: t.Any, default: t.Any = None) -> t.Any: """Gets the injected data.""" return self._data.get(type_, default) - async def _invoke_callback( - self, callback: t.Callable[..., T], *args: t.Any, **kwargs: t.Any - ) -> T: + async def _invoke_callback(self, callback: t.Callable[..., T], *args: t.Any, **kwargs: t.Any) -> T: parameters: t.Mapping[str, inspect.Parameter] try: parameters = inspect.signature(callback).parameters @@ -128,8 +123,7 @@ async def _invoke_callback( signatures: t.Dict[str, Data] = { k: v.default for k, v in parameters.items() - if v.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD - and isinstance(v.default, Data) + if v.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD and isinstance(v.default, Data) } for k, v in signatures.items(): diff --git a/topgg/http.py b/topgg/http.py index 08160d6..4333c4a 100644 --- a/topgg/http.py +++ b/topgg/http.py @@ -3,6 +3,7 @@ # The MIT License (MIT) # Copyright (c) 2021 Assanali Mukhanov +# Copyright (c) 2024 null8626 # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), @@ -28,6 +29,7 @@ import json import logging import sys +import warnings from datetime import datetime from typing import Any, Coroutine, Dict, Iterable, List, Optional, Sequence, Union, cast @@ -76,27 +78,17 @@ def __init__( self.token = token self._own_session = session is None self.session: aiohttp.ClientSession = session or aiohttp.ClientSession(**kwargs) - self.global_rate_limiter = AsyncRateLimiter( - max_calls=99, period=1, callback=_rate_limit_handler - ) - self.bot_rate_limiter = AsyncRateLimiter( - max_calls=59, period=60, callback=_rate_limit_handler - ) - self.rate_limiters = AsyncRateLimiterManager( - [self.global_rate_limiter, self.bot_rate_limiter] - ) + self.global_rate_limiter = AsyncRateLimiter(max_calls=99, period=1, callback=_rate_limit_handler) + self.bot_rate_limiter = AsyncRateLimiter(max_calls=59, period=60, callback=_rate_limit_handler) + self.rate_limiters = AsyncRateLimiterManager([self.global_rate_limiter, self.bot_rate_limiter]) self.user_agent = ( - f"topggpy (https://github.com/top-gg/python-sdk {__version__}) Python/" + f"topggpy (https://github.com/top-gg-community/python-sdk {__version__}) Python/" f"{sys.version_info[0]}.{sys.version_info[1]} aiohttp/{aiohttp.__version__}" ) async def request(self, method: str, endpoint: str, **kwargs: Any) -> dict: """Handles requests to the API.""" - rate_limiters = ( - self.rate_limiters - if endpoint.startswith("/bots") - else self.global_rate_limiter - ) + rate_limiters = self.rate_limiters if endpoint.startswith("/bots") else self.global_rate_limiter url = f"{self.BASE}{endpoint}" if not self.token: @@ -210,7 +202,13 @@ def get_bots( search: Dict[str, str], fields: Sequence[str], ) -> Coroutine[Any, Any, dict]: - """Gets an object of bots on Top.gg.""" + """ + Warning: + This function is deprecated. + """ + + warnings.warn("get_bots is now deprecated.", DeprecationWarning) + limit = min(limit, 500) fields = ", ".join(fields) search = " ".join([f"{field}: {value}" for field, value in search.items()]) @@ -240,9 +238,7 @@ async def _rate_limit_handler(until: float) -> None: """Handles the displayed message when we are ratelimited.""" duration = round(until - datetime.utcnow().timestamp()) mins = duration / 60 - fmt = ( - "We have exhausted a ratelimit quota. Retrying in %.2f seconds (%.3f minutes)." - ) + fmt = "We have exhausted a ratelimit quota. Retrying in %.2f seconds (%.3f minutes)." _LOGGER.warning(fmt, duration, mins) diff --git a/topgg/py.typed b/topgg/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/topgg/ratelimiter.py b/topgg/ratelimiter.py index 028a98e..d2d75f7 100644 --- a/topgg/ratelimiter.py +++ b/topgg/ratelimiter.py @@ -102,9 +102,4 @@ async def __aexit__( exc_val: BaseException, exc_tb: TracebackType, ) -> None: - await asyncio.gather( - *[ - manager.__aexit__(exc_type, exc_val, exc_tb) - for manager in self.rate_limiters - ] - ) + await asyncio.gather(*[manager.__aexit__(exc_type, exc_val, exc_tb) for manager in self.rate_limiters]) diff --git a/topgg/types.py b/topgg/types.py index 2da13f9..8bfd067 100644 --- a/topgg/types.py +++ b/topgg/types.py @@ -214,7 +214,7 @@ class BotData(DataDict[str, t.Any]): avatar: t.Optional[str] """The avatar hash of the bot.""" - def_avatar: str + def_avatar: t.Optional[str] """The avatar hash of the bot's default avatar.""" prefix: str diff --git a/topgg/webhook.py b/topgg/webhook.py index 4b94ec2..b3c961b 100644 --- a/topgg/webhook.py +++ b/topgg/webhook.py @@ -3,6 +3,7 @@ # The MIT License (MIT) # Copyright (c) 2021 Assanali Mukhanov +# Copyright (c) 2024 null8626 # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), @@ -70,31 +71,30 @@ class WebhookManager(DataContainerMixin): def __init__(self) -> None: super().__init__() - self.__app = web.Application() + + self.__app = None self._is_running = False @t.overload - def endpoint(self, endpoint_: None = None) -> "BoundWebhookEndpoint": - ... + def endpoint(self, endpoint_: None = None) -> "BoundWebhookEndpoint": ... @t.overload - def endpoint(self, endpoint_: "WebhookEndpoint") -> "WebhookManager": - ... + def endpoint(self, endpoint_: "WebhookEndpoint") -> "WebhookManager": ... def endpoint(self, endpoint_: t.Optional["WebhookEndpoint"] = None) -> t.Any: """Helper method that returns a WebhookEndpoint object. Args: - `endpoint_` (:obj:`typing.Optional` [ :obj:`WebhookEndpoint` ]) + `endpoint_` (Optional[:obj:`WebhookEndpoint`]) The endpoint to add. Returns: - :obj:`typing.Union` [ :obj:`WebhookManager`, :obj:`BoundWebhookEndpoint` ]: + Union[:obj:`WebhookManager`, :obj:`BoundWebhookEndpoint`]: An instance of :obj:`WebhookManager` if endpoint was provided, otherwise :obj:`BoundWebhookEndpoint`. Raises: - :obj:`~.errors.TopGGException` + :exc:`~.errors.TopGGException` If the endpoint is lacking attributes. """ if endpoint_: @@ -109,9 +109,7 @@ def endpoint(self, endpoint_: t.Optional["WebhookEndpoint"] = None) -> t.Any: self.app.router.add_post( endpoint_._route, - self._get_handler( - endpoint_._type, endpoint_._auth, endpoint_._callback - ), + self._get_handler(endpoint_._type, endpoint_._auth, endpoint_._callback), ) return self @@ -124,6 +122,8 @@ async def start(self, port: int) -> None: port (int) The port to run the webhook on. """ + + self.__app = web.Application() runner = web.AppRunner(self.__app) await runner.setup() self._webserver = web.TCPSite(runner, "0.0.0.0", port) @@ -147,12 +147,12 @@ def app(self) -> web.Application: async def close(self) -> None: """Stops the webhook.""" + await self._webserver.stop() + await self.__app.shutdown() self._is_running = False - def _get_handler( - self, type_: WebhookType, auth: str, callback: t.Callable[..., t.Any] - ) -> _HandlerT: + def _get_handler(self, type_: WebhookType, auth: str, callback: t.Callable[..., t.Any]) -> _HandlerT: async def _handler(request: aiohttp.web.Request) -> web.Response: if request.headers.get("Authorization", "") != auth: return web.Response(status=401, text="Unauthorized") @@ -225,12 +225,10 @@ def auth(self: T, auth_: str) -> T: return self @t.overload - def callback(self, callback_: None) -> t.Callable[[CallbackT], CallbackT]: - ... + def callback(self, callback_: None) -> t.Callable[[CallbackT], CallbackT]: ... @t.overload - def callback(self: T, callback_: CallbackT) -> T: - ... + def callback(self: T, callback_: CallbackT) -> T: ... def callback(self, callback_: t.Any = None) -> t.Any: """ @@ -245,25 +243,21 @@ def callback(self, callback_: t.Any = None) -> t.Any: import topgg webhook_manager = topgg.WebhookManager() - endpoint = ( - topgg.WebhookEndpoint() - .type(topgg.WebhookType.BOT) - .route("/dblwebhook") - .auth("youshallnotpass") - ) + endpoint = topgg.WebhookEndpoint().type(topgg.WebhookType.BOT).route("/dblwebhook").auth("youshallnotpass") # The following are valid. endpoint.callback(lambda vote_data: print("Receives a vote!", vote_data)) + # Used as decorator, the decorated function will become the WebhookEndpoint object. @endpoint.callback - def endpoint(vote_data: topgg.BotVoteData): - ... + def endpoint(vote_data: topgg.BotVoteData): ... + # Used as decorator factory, the decorated function will still be the function itself. @endpoint.callback() - def on_vote(vote_data: topgg.BotVoteData): - ... + def on_vote(vote_data: topgg.BotVoteData): ... + webhook_manager.endpoint(endpoint) """ @@ -286,25 +280,22 @@ class BoundWebhookEndpoint(WebhookEndpoint): import topgg webhook_manager = ( - topgg.WebhookManager() - .endpoint() - .type(topgg.WebhookType.BOT) - .route("/dblwebhook") - .auth("youshallnotpass") + topgg.WebhookManager().endpoint().type(topgg.WebhookType.BOT).route("/dblwebhook").auth("youshallnotpass") ) # The following are valid. endpoint.callback(lambda vote_data: print("Receives a vote!", vote_data)) + # Used as decorator, the decorated function will become the BoundWebhookEndpoint object. @endpoint.callback - def endpoint(vote_data: topgg.BotVoteData): - ... + def endpoint(vote_data: topgg.BotVoteData): ... + # Used as decorator factory, the decorated function will still be the function itself. @endpoint.callback() - def on_vote(vote_data: topgg.BotVoteData): - ... + def on_vote(vote_data: topgg.BotVoteData): ... + endpoint.add_to_manager() """ @@ -330,9 +321,7 @@ def add_to_manager(self) -> WebhookManager: return self.manager -def endpoint( - route: str, type: WebhookType, auth: str = "" -) -> t.Callable[[t.Callable[..., t.Any]], WebhookEndpoint]: +def endpoint(route: str, type: WebhookType, auth: str = "") -> t.Callable[[t.Callable[..., t.Any]], WebhookEndpoint]: """ A decorator factory for instantiating WebhookEndpoint. @@ -345,7 +334,7 @@ def endpoint( The auth for the endpoint. Returns: - :obj:`typing.Callable` [[ :obj:`typing.Callable` [..., :obj:`typing.Any` ]], :obj:`WebhookEndpoint` ]: + Callable[[Callable[..., Any]], :obj:`WebhookEndpoint`]: The actual decorator. :Example: @@ -353,13 +342,13 @@ def endpoint( import topgg + @topgg.endpoint("/dblwebhook", WebhookType.BOT, "youshallnotpass") async def on_vote( vote_data: topgg.BotVoteData, # database here is an injected data database: Database = topgg.data(Database), - ): - ... + ): ... """ def decorator(func: t.Callable[..., t.Any]) -> WebhookEndpoint: