9 |
10 | ### ✨ Features
11 |
12 | - #### Speed
13 |
14 | We try to make the library as fast as possible, without compromising on readability of the code or features.
15 |
16 |
17 |
18 | - #### Modularity
19 |
20 | All the components can easily be swapped out with your own.
21 |
22 | - #### Control
23 |
24 | Nextcore offers fine-grained control over things most libraries don't support.
25 |
26 | This currently includes:
27 | - Setting priority for individual requests
28 | - Swapping out components
29 |
30 |
31 |
32 |
33 |
34 | # Examples
35 |
36 |
37 |
38 | ### 🏓 Ping pong
39 | A simple "ping pong" example in nextcore.
40 | This will respond with "pong" each time someone sends "ping" in the chat.
41 | ```py
42 | import asyncio
43 | from os import environ
44 | from typing import cast
45 |
46 | from discord_typings import MessageData
47 |
48 | from nextcore.gateway import ShardManager
49 | from nextcore.http import BotAuthentication, HTTPClient, Route
50 |
51 | # Constants
52 | AUTHENTICATION = BotAuthentication(environ["TOKEN"])
53 |
54 | # Intents are a way to select what intents Discord should send to you.
55 | # For a list of intents see https://discord.dev/topics/gateway#gateway-intents
56 | GUILD_MESSAGES_INTENT = 1 << 9
57 | MESSAGE_CONTENT_INTENT = 1 << 15
58 |
59 | INTENTS = GUILD_MESSAGES_INTENT | MESSAGE_CONTENT_INTENT # Guild messages and message content intents.
60 |
61 |
62 | # Create a HTTPClient and a ShardManager.
63 | # A ShardManager is just a neat wrapper around Shard objects.
64 | http_client = HTTPClient()
65 | shard_manager = ShardManager(AUTHENTICATION, INTENTS, http_client)
66 |
67 |
68 | @shard_manager.event_dispatcher.listen("MESSAGE_CREATE")
69 | async def on_message(message: MessageData):
70 | # This function will be called every time a message is sent.
71 | if message["content"] == "ping":
72 | # Send a pong message to respond.
73 | route = Route("POST", "/channels/{channel_id}/messages", channel_id=message["channel_id"])
74 |
75 | await http_client.request(
76 | route,
77 | rate_limit_key=AUTHENTICATION.rate_limit_key,
78 | json={"content": "pong"},
79 | headers=AUTHENTICATION.headers,
80 | )
81 |
82 |
83 | async def main():
84 | await http_client.setup()
85 |
86 | # This should return once all shards have started to connect.
87 | # This does not mean they are connected.
88 | await shard_manager.connect()
89 |
90 | # Raise a error and exit whenever a critical error occurs
91 | (error,) = await shard_manager.dispatcher.wait_for(lambda: True, "critical")
92 |
93 | raise cast(Exception, error)
94 |
95 |
96 | asyncio.run(main())
97 | ```
98 |
99 | > More examples can be seen in the [examples](examples/) directory.
100 |
101 |
102 |
103 | ## Contributing
104 | Want to help us out? Please read our [contributing](https://nextcore.readthedocs.io/en/latest/contributing/getting_started.html) docs.
105 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line, and also
5 | # from the environment for the first two.
6 | SPHINXOPTS ?= -j auto
7 | SPHINXBUILD ?= sphinx-build
8 | SOURCEDIR = .
9 | BUILDDIR = _build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
--------------------------------------------------------------------------------
/docs/_static/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nextsnake/nextcore/05e3cd74e6b068256df2a73224aa5236838ba0f9/docs/_static/.gitkeep
--------------------------------------------------------------------------------
/docs/_static/logo.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nextsnake/nextcore/05e3cd74e6b068256df2a73224aa5236838ba0f9/docs/_static/logo.ico
--------------------------------------------------------------------------------
/docs/_static/logo.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/docs/_static/speed-showcase.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nextsnake/nextcore/05e3cd74e6b068256df2a73224aa5236838ba0f9/docs/_static/speed-showcase.mp4
--------------------------------------------------------------------------------
/docs/common.rst:
--------------------------------------------------------------------------------
1 | .. currentmodule:: nextcore.common
2 |
3 | :og:title: Nextcore common documentation
4 | :og:description: Documentation for the common part of nextcore. This is for things that does not neccesarily fit into any specific module in nextcore.
5 |
6 | Common
7 | ======
8 | Common reference
9 | ----------------
10 |
11 | .. autoclass:: Dispatcher
12 | :members:
13 |
14 | .. autoclass:: TimesPer
15 | :members:
16 |
17 | .. autoclass:: UndefinedType
18 | :members:
19 |
20 | .. data:: Undefined
21 | :type: UndefinedType
22 |
23 | A alias for :attr:`UndefinedType.UNDEFINED`
24 |
25 | Errors
26 | ------
27 | .. automodule:: nextcore.common.errors
28 | :members:
29 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # This file only contains a selection of the most common options. For a full
4 | # list see the documentation:
5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
6 |
7 | # -- Path setup --------------------------------------------------------------
8 |
9 | # If extensions (or modules to document with autodoc) are in another directory,
10 | # add these directories to sys.path here. If the directory is relative to the
11 | # documentation root, use os.path.abspath to make it absolute, like shown here.
12 | #
13 | import os
14 | import sys
15 |
16 | sys.path.insert(0, os.path.abspath(".."))
17 |
18 |
19 | # -- Project information -----------------------------------------------------
20 |
21 | project = "nextcore"
22 | copyright = "2022 tag-epic"
23 | author = "tag-epic"
24 |
25 |
26 | # -- General configuration ---------------------------------------------------
27 |
28 | # Add any Sphinx extension module names here, as strings. They can be
29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
30 | # ones.
31 | extensions = [
32 | "sphinx.ext.autodoc",
33 | "sphinx.ext.napoleon",
34 | "sphinx.ext.intersphinx",
35 | "sphinx.ext.autosectionlabel",
36 | "sphinxext.opengraph",
37 | "sphinx_inline_tabs",
38 | ]
39 | autodoc_typehints = "description"
40 |
41 | # Add any paths that contain templates here, relative to this directory.
42 | templates_path = ["_templates"]
43 |
44 | # List of patterns, relative to source directory, that match files and
45 | # directories to ignore when looking for source files.
46 | # This pattern also affects html_static_path and html_extra_path.
47 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
48 |
49 | # -- Extension configuration -------------------------------------------------
50 | # Intersphinx
51 | intersphinx_mapping = {
52 | "aiohttp": ("https://docs.aiohttp.org/en/stable/", None),
53 | "python": ("https://docs.python.org/3/", None),
54 | "towncrier": ("https://towncrier.readthedocs.io/en/stable", None),
55 | }
56 |
57 | # Opengraph
58 | ogp_image = "/_static/logo.svg"
59 | ogp_site_name = "Nextcore Documentation"
60 |
61 | # -- Options for HTML output -------------------------------------------------
62 |
63 | # The theme to use for HTML and HTML Help pages. See the documentation for
64 | # a list of builtin themes.
65 | #
66 | html_theme = "furo"
67 |
68 | # Add any paths that contain custom static files (such as style sheets) here,
69 | # relative to this directory. They are copied after the builtin static files,
70 | # so a file named "default.css" will overwrite the builtin "default.css".
71 | html_static_path = ["_static"]
72 | html_logo = "_static/logo.svg"
73 | html_favicon = "_static/logo.ico"
74 |
--------------------------------------------------------------------------------
/docs/contributing/getting_started.rst:
--------------------------------------------------------------------------------
1 | :og:title: Nextcore contribution guide
2 | :og:description: Info about getting started helping out with the nextcore project!
3 |
4 | Contributing
5 | =============
6 |
7 | Setting up a development environment
8 | --------------------------------------
9 | First, you will need to fork the `repository `__.
10 |
11 | Then you will need to setup a ssh-key. See `GitHub's tutorial `__.
12 |
13 | .. code-block:: sh
14 |
15 | git clone https://github.com//nextcore
16 |
17 | cd nextcore
18 |
19 | poetry install
20 |
21 | git checkout -b
22 |
23 | poetry shell # You will need to run this every time you need access to your development version of nextcore.
24 |
25 | We recommend making a "development" folder and adding it to your .git/info/exclude file.
26 |
27 | This can be done by making a ``.dev`` folder and appending ``.dev`` to the .git/info/exclude file on a new line.
28 | Every file in the ``.dev`` directory will now be invisible to git.
29 |
30 | Submitting your changes
31 | -------------------------
32 | Before submitting we recommend checking a few things
33 |
34 | 1. You have linted your code. This can be done by running ``task lint``
35 | 2. You have checked your code for type errors. This can be done by running ``pyright``
36 |
37 | .. hint::
38 | Pyright needs to be installed. This can be done by using ``npm install --global pyright``
39 |
40 | 3. Did you remember to write a changelog? See :external+towncrier:doc:`the towncrier tutorial `
41 |
42 | Do's and Dont's
43 | ----------------
44 | - Do keep your PR scope small. We would rather review many small PRs with seperate features than one giant one.
45 | - Make draft PRs.
46 |
47 | Project structure
48 | ------------------
49 | Nextcore is currently split into 3 main modules
50 |
51 | - nextcore.common
52 | - nextcore.http
53 | - nextcore.gateway
54 |
55 | Common is for common utilies that needs to be shared between the other modules.
56 | HTTP is for the REST API.
57 | Gateway is for the WebSocket gateway.
58 |
--------------------------------------------------------------------------------
/docs/events.rst:
--------------------------------------------------------------------------------
1 | .. currentmodule:: nextcore
2 |
3 | :og:title: Nextcore event reference
4 | :og:description: This gives you examples for some Dispatcher events.
5 |
6 | Events
7 | ======
8 | This is a document showing you the arguments from the different instances of :class:`common.Dispatcher` in the library.
9 |
10 | Raw Dispatcher
11 | --------------
12 | Can be found on :attr:`ShardManadger.raw_dispatcher ` and :attr:`Shard.raw_dispatcher `.
13 | These are the raw dispatchers that just relay raw events from the discord websocket (the gateway).
14 |
15 | The event name here is the gateway `opcode `__.
16 |
17 | **Example usage:**
18 |
19 | .. code-block:: python
20 |
21 | @shard.listen(GatewayOpcode.HEARTBEAT_ACK)
22 | async def on_heartbeat_ack(data):
23 | print("<3")
24 |
25 | Event Dispatcher
26 | ----------------
27 | Can be found on :attr:`ShardManadger.event_dispatcher ` and :attr:`Shard.event_dispatcher `.
28 | These dispatchers dispatch the data inside the ``d`` key of a :attr:`GatewayOpcode.DISPATCH` event.
29 |
30 | The event name is the Dispatch `event name `__.
31 |
32 | **Example usage:**
33 |
34 | .. code-block:: python
35 |
36 | @shard.listen("READY")
37 | async def on_ready(data: ReadyData):
38 | print(f"Logged in as {data.user.username}#{data.user.discriminator}")
39 |
40 | Shard dispatcher
41 | ----------------
42 | Can be found on :attr:`Shard.dispatcher `.
43 | A dispatcher for shard changes that is not a event sent by Discord.
44 |
45 | disconnect
46 | ^^^^^^^^^^
47 | Whenever Discord disconnects us from the gateway.
48 |
49 | .. note::
50 | This does not dispatch closes made by the :class:`gateway.Shard` itself.
51 |
52 | **Example usage:**
53 |
54 | .. code-block:: python
55 |
56 | @shard.listen("disconnect")
57 | async def on_disconnect(close_code: int):
58 | print(f"Disconnected with close code {close_code}")
59 |
60 | client_disconnect
61 | ^^^^^^^^^^^^^^^^^
62 | Whenever we disconnect from the Discord gateway.
63 |
64 | **Example usage:**
65 |
66 | .. code-block:: python
67 |
68 | @shard.listen("client_disconnect")
69 | async def on_client_disconnect(close: bool) -> None:
70 | if close:
71 | print("We deleted and deleted the session! We should not be able to resume.")
72 | else:
73 | print("We disconnected and can still resume!")
74 |
75 |
76 | sent
77 | ^^^^
78 | Whenever we send a message to Discord over the websocket.
79 |
80 | **Example usage:**
81 |
82 | .. code-block:: python
83 |
84 | @shard.listen("sent")
85 | async def on_sent(data: dict):
86 | print(f"Sent {data}")
87 |
88 | critical
89 | ^^^^^^^^
90 | Whenever a critical event happens, this event is dispatched. The first argument will be a :class:`Exception` object of what happened.
91 |
92 | **Example usage:**
93 |
94 | .. code-block:: python
95 |
96 | @shard.dispatcher.listen("critical")
97 | async def on_critical(error):
98 | print(f"Critical error: {error}")
99 |
100 | HTTPClient dispatcher
101 | ---------------------
102 | Can be found on :attr:`HTTPClient.dispatcher `.
103 |
104 | The event name is a :class:`str` representing the event name.
105 |
106 | request_response
107 | ^^^^^^^^^^^^^^^^
108 | Whenever a response to a request to Discord has been received, this event is dispatcher. The first argument will be the :class:`aiohttp.ClientResponse` object.
109 |
110 | **Example usage:**
111 |
112 | .. code-block:: python
113 |
114 | @http_client.dispatcher.listen("request_response")
115 | async def on_request_response(response: aiohttp.ClientResponse):
116 | print(f"Status code: {response.status}")
117 |
--------------------------------------------------------------------------------
/docs/gateway.rst:
--------------------------------------------------------------------------------
1 | .. currentmodule:: nextcore.gateway
2 |
3 | :og:title: Nextcore gateway documentation
4 | :og:description: Documentation for the gateway part of nextcore. This is for things like connecting as a bot and receiving messages.
5 |
6 | Gateway
7 | =======
8 |
9 | Gateway quickstart
10 | ------------------
11 |
12 | .. hint::
13 | We recommend that you read the :ref:`Making requests` tutorial before this, as we will not explain HTTP concepts here.
14 | .. hint::
15 | The finished example can be found `here `__
16 | .. note::
17 | We will use await at the top level here as it's easier to explain. For your own code, please use an async function.
18 |
19 | If you want a example of how it can be done in an async function, see the full example.
20 |
21 | Setting up
22 | ^^^^^^^^^^
23 | .. code-block:: python3
24 |
25 | import asyncio
26 | from os import environ
27 | from typing import cast
28 |
29 | from discord_typings import MessageData
30 |
31 | from nextcore.gateway import ShardManager
32 | from nextcore.http import BotAuthentication, HTTPClient, Route
33 |
34 | # Constants
35 | AUTHENTICATION = BotAuthentication(environ["TOKEN"])
36 |
37 | Intents
38 | ^^^^^^^
39 | Discord uses intents to select what you want to be received from the gateway to reduce wasted resources.
40 | This is done via bitflags.
41 |
42 | A list of intents can be found on the `intents page `__
43 |
44 | In this example we want the message intent, and the message content intent.
45 |
46 | .. code-block:: python3
47 |
48 | GUILD_MESSAGES_INTENT = 1 << 9
49 | MESSAGE_CONTENT_INTENT = 1 << 15
50 |
51 | .. note::
52 | This can also be stored in binary representation.
53 |
54 | .. code-block:: python3
55 |
56 | GUILD_MESSAGES_INTENT = 0b1000000000
57 | MESSAGE_CONTENT_INTENT = 0b1000000000000000
58 |
59 | If you want to use multiple intents, you can combine them using bitwise or (``|``).
60 |
61 | .. code-block:: python3
62 |
63 | INTENTS = GUILD_MESSAGES_INTENT | MESSAGE_CONTENT_INTENT
64 |
65 | Now just give it to the :class:`ShardManager`.
66 |
67 | .. code-block:: python3
68 |
69 | http_client = HTTPClient()
70 | shard_manager = ShardManager(AUTHENTICATION, INTENTS, http_client)
71 |
72 | .. note::
73 | Discord marks the ``MESSAGE_CONTENT_INTENT`` as "privileged", meaning you have to turn on a switch in the `developer portal `__
74 |
75 | Creating a listener
76 | ^^^^^^^^^^^^^^^^^^^
77 | Lets create a listener for whenever someone sends a message
78 |
79 | A list of events can be found on the `Receive events `__ page
80 |
81 | .. code-block:: python3
82 |
83 | @shard_manager.event_dispatcher.listen("MESSAGE_CREATE")
84 | async def on_message(message: MessageData):
85 | # This function will be called every time a message is sent.
86 |
87 | Now just check if someone said ``ping`` and respond with ``pong``
88 |
89 | .. code-block:: python3
90 |
91 | if message["content"] == "ping":
92 | # Send a pong message to respond.
93 | route = Route("POST", "/channels/{channel_id}/messages", channel_id=message["channel_id"])
94 |
95 | await http_client.request(
96 | route,
97 | rate_limit_key=AUTHENTICATION.rate_limit_key,
98 | json={"content": "pong"},
99 | headers=AUTHENTICATION.headers,
100 | )
101 |
102 | .. hint::
103 | Confused by this? Check out the :ref:`Making requests` tutorial!
104 |
105 | Connecting to Discord
106 | ^^^^^^^^^^^^^^^^^^^^^
107 | .. code-block:: python3
108 |
109 | await http_client.setup()
110 | await shard_manager.connect()
111 |
112 | .. need absolute ref for HTTPClient as its in the http module
113 | .. warning::
114 | :meth:`HTTPClient.setup() ` needs to be called before :meth:`ShardManager.connect`
115 | .. note::
116 | :meth:`ShardManager.connect` will return once every shard has started to connect
117 |
118 | Stopping the script from stopping
119 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
120 | Since the :meth:`ShardManager.connect` function returns once every shard has started to connect, the script closes as the main thread has nothing to do.
121 |
122 | We can wait for a critical error before closing to fix this.
123 |
124 | .. code-block:: python3
125 |
126 | (error,) = await shard_manager.dispatcher.wait_for(lambda: True, "critical")
127 |
128 | raise cast(Exception, error)
129 |
130 | .. note::
131 | The weird ``(error, )`` thing is to extract the first element out of the tuple.
132 |
133 | Continuing
134 | ^^^^^^^^^^
135 | We suggest that you look into `interactions & application commands `__ as your next topic.
136 | They allow you to add `buttons `__ and `slash commands `__ and other cool stuff!
137 |
138 |
139 | Gateway reference
140 | -----------------
141 | .. autoclass:: ShardManager
142 | :members:
143 |
144 | .. autoclass:: Shard
145 | :members:
146 |
147 | .. autoclass:: GatewayOpcode
148 | :members:
149 |
150 | .. autoclass:: Decompressor
151 | :members:
152 |
153 | Gateway errors
154 | --------------
155 | .. autoexception:: ReconnectCheckFailedError
156 | :members:
157 |
158 | .. autoexception:: DisconnectError
159 | :members:
160 |
161 | .. autoexception:: InvalidIntentsError
162 | :members:
163 |
164 | .. autoexception:: DisallowedIntentsError
165 | :members:
166 |
167 | .. autoexception:: InvalidTokenError
168 | :members:
169 |
170 | .. autoexception:: InvalidApiVersionError
171 | :members:
172 |
173 | .. autoexception:: InvalidShardCountError
174 | :members:
175 |
176 | .. autoexception:: UnhandledCloseCodeError
177 | :members:
178 |
--------------------------------------------------------------------------------
/docs/http.rst:
--------------------------------------------------------------------------------
1 | .. currentmodule:: nextcore.http
2 |
3 | :og:title: Nextcore HTTP documentation
4 | :og:description: Documentation for the HTTP part of nextcore. This is for things like making HTTP requests and HTTP specific rate limiting.
5 |
6 | HTTP
7 | ====
8 |
9 | Making requests
10 | ---------------
11 |
12 | .. hint::
13 | The finished example can be found `here `__
14 | .. note::
15 | We will use await at the top level here as its easier to explain. For your own code, please use a async function.
16 |
17 | If you want a example of how it can be done in a async function, see the full example.
18 |
19 | Setting up
20 | ^^^^^^^^^^^
21 | .. code-block:: python3
22 |
23 | from os import environ
24 |
25 | from discord_typings import ChannelData
26 | from nextcore.http import BotAuthentication, HTTPClient, Route
27 |
28 | # Constants
29 | AUTHENTICATION = BotAuthentication(environ["TOKEN"])
30 | CHANNEL_ID = environ["CHANNEL_ID"]
31 |
32 | # HTTP Client
33 | http_client = HTTPClient()
34 | await http_client.setup()
35 |
36 | .. note::
37 | You will need to set environment variables on your system for this to work.
38 |
39 | .. tab:: Bash
40 |
41 | .. code-block:: bash
42 |
43 | export TOKEN="..."
44 | export CHANNEL_ID="..."
45 | .. tab:: PowerShell
46 |
47 | .. code-block:: powershell
48 |
49 | $env:TOKEN = "..."
50 | $env:CHANNEL_ID = "..."
51 |
52 | Creating a :class:`Route`
53 | ^^^^^^^^^^^^^^^^^^^^^^^^^
54 | First you need to find what route you are going to implement. A list can be found on https://discord.dev
55 |
56 | For this example we are going to use `Get Channel `__
57 |
58 | For the first parameter, this will be the HTTP method.
59 |
60 | .. code-block:: python3
61 |
62 | route = Route("GET", ...)
63 |
64 | The second parameter is the "route". This is the path of the request, without any parameters included.
65 | To get this, you take the path (``/channels/{channel.id}``) and replace ``.`` with ``_``
66 | It will look something like this.
67 |
68 | .. code-block:: python3
69 |
70 | route = Route("GET", "/channels/{channel_id}", ...)
71 |
72 | .. warning::
73 | This should not be a f-string!
74 |
75 |
76 | The kwargs will be parameters to the path.
77 |
78 | .. code-block:: python3
79 |
80 | route = Route("GET", "/channels/{channel_id}", channel_id=CHANNEL_ID)
81 |
82 | Doing the request
83 | ^^^^^^^^^^^^^^^^^^
84 | To do a request, you will need to use :meth:`HTTPClient.request`.
85 |
86 | .. code-block:: python3
87 |
88 | response = await http_client.request(route,
89 | rate_limit_key=AUTHENTICATION.rate_limit_key,
90 | headers=AUTHENTICATION.headers
91 | )
92 |
93 | This will return a :class:`aiohttp.ClientResponse` for you to process.
94 |
95 | Getting the response data
96 | ^^^^^^^^^^^^^^^^^^^^^^^^^
97 |
98 | We can use :meth:`aiohttp.ClientResponse.json` to get the JSON response data.
99 |
100 | .. code-block:: python3
101 |
102 | channel = await response.json()
103 |
104 | And finally, get the channel name
105 |
106 | .. code-block:: python3
107 |
108 | print(channel.get("name")) # DM channels do not have names
109 |
110 | Cleaning up
111 | ^^^^^^^^^^^
112 | When you are finished with all requests, you can close the HTTP client gracefully.
113 |
114 | .. code-block:: python3
115 |
116 | await http_client.close()
117 |
118 | HTTP reference
119 | --------------
120 | .. autoclass:: HTTPClient
121 | :members:
122 | :inherited-members:
123 |
124 | .. autoclass:: Route
125 | :members:
126 |
127 | .. autoclass:: RateLimitStorage
128 | :members:
129 |
130 | Authentication
131 | ^^^^^^^^^^^^^^^
132 | .. autoclass:: BaseAuthentication
133 | :members:
134 |
135 | .. autoclass:: BotAuthentication
136 | :members:
137 |
138 | .. autoclass:: BearerAuthentication
139 | :members:
140 |
141 |
142 | Bucket rate limiting
143 | ^^^^^^^^^^^^^^^^^^^^^
144 | .. autoclass:: Bucket
145 | :members:
146 |
147 | .. autoclass:: BucketMetadata
148 | :members:
149 |
150 | .. autoclass:: RequestSession
151 | :members:
152 |
153 | Global rate limiting
154 | ^^^^^^^^^^^^^^^^^^^^
155 |
156 | .. autoclass:: BaseGlobalRateLimiter
157 | :members:
158 |
159 | .. autoclass:: LimitedGlobalRateLimiter
160 | :members:
161 |
162 | .. autoclass:: UnlimitedGlobalRateLimiter
163 | :members:
164 |
165 | HTTP errors
166 | -----------
167 | .. autoexception:: RateLimitingFailedError
168 | :members:
169 |
170 | .. autoexception:: CloudflareBanError
171 | :members:
172 |
173 | .. autoexception:: HTTPRequestStatusError
174 | :members:
175 |
176 | .. autoexception:: BadRequestError
177 | :members:
178 |
179 | .. autoexception:: NotFoundError
180 | :members:
181 |
182 | .. autoexception:: UnauthorizedError
183 | :members:
184 |
185 | .. autoexception:: ForbiddenError
186 | :members:
187 |
188 | .. autoexception:: InternalServerError
189 | :members:
190 |
191 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | :og:description: A super fast low level Discord library for full control.
2 |
3 | Welcome to Nextcore's documentation!
4 | ====================================
5 |
6 | .. toctree::
7 | :maxdepth: 1
8 | :caption: Contents:
9 |
10 | http
11 | gateway
12 | common
13 | events
14 |
15 | releasenotes
16 | optimizing
17 | contributing/getting_started
18 |
19 | Quickstart
20 | ==========
21 | First, you need to install the library.
22 |
23 | .. tab:: Pip
24 |
25 | .. code-block:: shell
26 |
27 | pip install nextcore
28 |
29 | .. tab:: Poetry
30 |
31 | .. code-block:: shell
32 |
33 | poetry add nextcore
34 |
35 | The documentation will now split into different pages depending on what functionality you need.
36 |
37 | - :ref:`http` Sending requests to discord.
38 | - :ref:`gateway` Receiving events from discord.
39 |
40 | .. warning::
41 |
42 | Using :data:`logging.DEBUG` logging may include secrets.
43 |
44 |
45 | Helping out
46 | =============
47 | We would appriciate your help writing nextcore and related libraries.
48 | See :ref:`contributing` for more info
49 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=.
11 | set BUILDDIR=_build
12 |
13 | if "%1" == "" goto help
14 |
15 | %SPHINXBUILD% >NUL 2>NUL
16 | if errorlevel 9009 (
17 | echo.
18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
19 | echo.installed, then set the SPHINXBUILD environment variable to point
20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
21 | echo.may add the Sphinx directory to PATH.
22 | echo.
23 | echo.If you don't have Sphinx installed, grab it from
24 | echo.https://www.sphinx-doc.org/
25 | exit /b 1
26 | )
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/docs/optimizing.rst:
--------------------------------------------------------------------------------
1 | Optimizing
2 | ==========
3 | Heres a few tips to make your bot run a tiny bit faster, from most impact to least. The accuracy of the ordering is more of a guess, and depends on your usage.
4 |
5 | .. Adjust the global rate limit
6 | .. ----------------------------
7 | .. TODO: This needs to be properly supported in HTTPClient first imo.
8 |
9 | Relative time
10 | -------------
11 | Nextcore uses your computer's time to work with rate limits.
12 |
13 | By default this is on, however your clock might not be syncronized.
14 |
15 |
16 |
17 | .. tab:: Ubuntu
18 |
19 | You can check if your clock is synchronized by running the following command:
20 |
21 | .. code-block:: bash
22 |
23 | timedatectl
24 |
25 | If it is synchronized, it will show "System clock synchronized: yes" and "NTP service: running"
26 |
27 | If the system clock is not synchronized but the ntp service is running you will have to wait a few minutes for it to sync.
28 |
29 | To enable the ntp service run the following command:
30 |
31 | .. code-block:: bash
32 |
33 | sudo timedatectl set-ntp on
34 |
35 | This will automatically sync the system clock every once in a while.
36 |
37 | .. tab:: Arch
38 |
39 | You can check if your clock is synchronized by running the following command:
40 |
41 | .. code-block:: bash
42 |
43 | timedatectl
44 |
45 | If it is synchronized, it will show "System clock synchronized: yes" and "NTP service: running"
46 |
47 | If the system clock is not synchronized but the ntp service is running you will have to wait a few minutes for it to sync.
48 |
49 | To enable the ntp service run the following command:
50 |
51 | .. code-block:: bash
52 |
53 | sudo timedatectl set-ntp on
54 |
55 | This will automatically sync the system clock every once in a while.
56 |
57 | .. tab:: Windows
58 |
59 | This can be turned on by going to ``Settings -> Time & language -> Date & time`` and turning on ``Set time automatically``.
60 |
61 | Switch to ORJSON
62 | ----------------
63 | Nextcore handles quite a bit of JSON encoding and decoding.
64 | By default, nextcore uses the :mod:`json` module, which is quite a bit slower than :mod:`orjson`
65 |
66 | You can switch to ORJSON by installing the speed package and setting it as the global aiohttp default
67 |
68 | .. tab:: Pip
69 |
70 | .. code-block:: bash
71 |
72 | pip install "nextcore[speed]"
73 |
74 | .. tab:: Poetry
75 |
76 | .. code-block:: bash
77 |
78 | poetry add "nextcore[speed]"
79 |
80 | This will make :mod:`nextcore.gateway` use orjson, if it is installed.
81 |
82 | .. TODO: How do we enable it for nextcore.http too?
83 |
--------------------------------------------------------------------------------
/docs/releasenotes.rst:
--------------------------------------------------------------------------------
1 | Release notes
2 | =============
3 |
4 | .. towncrier release notes start
5 |
6 | Nextcore 2.0.2 (2024-03-21)
7 | ===========================
8 |
9 | Bugfixes
10 | --------
11 |
12 | - API version wasn't set when connecting to the gateway (api-version)
13 |
14 |
15 | Nextcore 2.0.1 (2023-07-10)
16 | ===========================
17 |
18 | Bugfixes
19 | --------
20 |
21 | - Stop reconnect-loop on low-latency network connections (#251)
22 |
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | Sphinx>=4.4.0
2 | sphinx-copybutton>=0.4.0
3 | furo>=2022.1.2
4 |
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 | # Nextcore examples
2 | ## Structure
3 | Nextcore examples is split into the same modules nextcore is split into
4 |
5 | So examples in the `http` directory would be for stuff in the `nextcore.http` module.
6 |
7 | ## Docs
8 | Some of the examples have full tutorials in the docs.
9 | For example the [get channel example](http/get_channel) also has a [tutorial in the docs](https://nextcore.readthedocs.io/en/latest/http.html#making-requests)
10 |
11 |
--------------------------------------------------------------------------------
/examples/gateway/ping_pong/ping_pong.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | #
3 | # Copyright (c) 2022-present tag-epic
4 | #
5 | # Permission is hereby granted, free of charge, to any person obtaining a
6 | # copy of this software and associated documentation files (the "Software"),
7 | # to deal in the Software without restriction, including without limitation
8 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
9 | # and/or sell copies of the Software, and to permit persons to whom the
10 | # Software is furnished to do so, subject to the following conditions:
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | """
23 | A simple "ping pong" example of using nextcore.
24 |
25 | This will respond with "pong" every time someone sends "ping" in the chat.
26 | """
27 |
28 | import asyncio
29 | from os import environ
30 | from typing import cast
31 |
32 | from discord_typings import MessageData
33 |
34 | from nextcore.gateway import ShardManager
35 | from nextcore.http import BotAuthentication, HTTPClient, Route
36 |
37 | # Constants
38 | AUTHENTICATION = BotAuthentication(environ["TOKEN"])
39 |
40 | # Intents are a way to select what intents Discord should send to you.
41 | # For a list of intents see https://discord.dev/topics/gateway#gateway-intents
42 | GUILD_MESSAGES_INTENT = 1 << 9
43 | MESSAGE_CONTENT_INTENT = 1 << 15
44 |
45 | INTENTS = GUILD_MESSAGES_INTENT | MESSAGE_CONTENT_INTENT # Guild messages and message content intents.
46 |
47 |
48 | # Create a HTTPClient and a ShardManager.
49 | # A ShardManager is just a neat wrapper around Shard objects.
50 | http_client = HTTPClient()
51 | shard_manager = ShardManager(AUTHENTICATION, INTENTS, http_client)
52 |
53 |
54 | @shard_manager.event_dispatcher.listen("MESSAGE_CREATE")
55 | async def on_message(message: MessageData):
56 | # This function will be called every time a message is sent.
57 | if message["content"] == "ping":
58 | # Send a pong message to respond.
59 | route = Route("POST", "/channels/{channel_id}/messages", channel_id=message["channel_id"])
60 |
61 | await http_client.request(
62 | route,
63 | rate_limit_key=AUTHENTICATION.rate_limit_key,
64 | json={"content": "pong"},
65 | headers=AUTHENTICATION.headers,
66 | )
67 |
68 |
69 | async def main():
70 | await http_client.setup()
71 |
72 | # This should return once all shards have started to connect.
73 | # This does not mean they are connected.
74 | await shard_manager.connect()
75 |
76 | # Raise a error and exit whenever a critical error occurs
77 | (error,) = await shard_manager.dispatcher.wait_for(lambda: True, "critical")
78 |
79 | raise cast(Exception, error)
80 |
81 |
82 | asyncio.run(main())
83 |
--------------------------------------------------------------------------------
/examples/gateway/ping_pong_slash_command/README.md:
--------------------------------------------------------------------------------
1 | # Ping pong - slash command edition
2 | ## Environment variables
3 | The following environment variables is required
4 |
5 | 1. TOKEN
6 | This can be found in your developer dashboard.
7 | 2. APPLICATION_ID
8 | This is usually the ID of your bot.
9 |
10 | ## Creating the commands
11 | Please run the [register_commands script](register_commands.py)
12 |
13 | After that just run the example
14 |
--------------------------------------------------------------------------------
/examples/gateway/ping_pong_slash_command/ping_pong_slash_command.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | #
3 | # Copyright (c) 2022-present tag-epic
4 | #
5 | # Permission is hereby granted, free of charge, to any person obtaining a
6 | # copy of this software and associated documentation files (the "Software"),
7 | # to deal in the Software without restriction, including without limitation
8 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
9 | # and/or sell copies of the Software, and to permit persons to whom the
10 | # Software is furnished to do so, subject to the following conditions:
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | """
23 | A simple "ping pong" example of using nextcore, but in slash commands.
24 |
25 | This will respond with "pong" every time someone sends "ping" in the chat.
26 | """
27 |
28 | import asyncio
29 | from os import environ
30 | from typing import cast
31 |
32 | from discord_typings import InteractionCreateData
33 |
34 | from nextcore.gateway import ShardManager
35 | from nextcore.http import BotAuthentication, HTTPClient, Route
36 |
37 | # Constants
38 | AUTHENTICATION = BotAuthentication(environ["TOKEN"])
39 |
40 | # Intents are a way to select what intents Discord should send to you.
41 | # For a list of intents see https://discord.dev/topics/gateway#gateway-intents
42 | INTENTS = 0 # Guild messages and message content intents.
43 |
44 |
45 | # Create a HTTPClient and a ShardManager.
46 | # A ShardManager is just a neat wrapper around Shard objects.
47 | http_client = HTTPClient()
48 | shard_manager = ShardManager(AUTHENTICATION, INTENTS, http_client)
49 |
50 |
51 | @shard_manager.event_dispatcher.listen("INTERACTION_CREATE")
52 | async def on_interaction_create(interaction: InteractionCreateData):
53 | # Only accept application comamnds. See https://discord.dev/interactions/receiving-and-responding#interaction-object-interaction-type for a list of types
54 | if interaction["type"] != 2:
55 | return
56 | # Only respond to the ping command
57 | if interaction["data"]["name"] != "ping":
58 | return
59 |
60 | response = {"type": 4, "data": {"content": "Pong!"}}
61 |
62 | route = Route(
63 | "POST",
64 | "/interactions/{interaction_id}/{interaction_token}/callback",
65 | interaction_id=interaction["id"],
66 | interaction_token=interaction["token"],
67 | )
68 | # Authentication is interaction id and interaction token, not bot authenication
69 | await http_client.request(route, rate_limit_key=None, json=response)
70 |
71 |
72 | async def main():
73 | await http_client.setup()
74 |
75 | # This should return once all shards have started to connect.
76 | # This does not mean they are connected.
77 | await shard_manager.connect()
78 |
79 | # Raise a error and exit whenever a critical error occurs
80 | (error,) = await shard_manager.dispatcher.wait_for(lambda: True, "critical")
81 |
82 | raise cast(Exception, error)
83 |
84 |
85 | asyncio.run(main())
86 |
--------------------------------------------------------------------------------
/examples/gateway/ping_pong_slash_command/register_commands.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | #
3 | # Copyright (c) 2022-present tag-epic
4 | #
5 | # Permission is hereby granted, free of charge, to any person obtaining a
6 | # copy of this software and associated documentation files (the "Software"),
7 | # to deal in the Software without restriction, including without limitation
8 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
9 | # and/or sell copies of the Software, and to permit persons to whom the
10 | # Software is furnished to do so, subject to the following conditions:
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | """
23 | The register script for this example.
24 |
25 | This will register the "ping" slash command we need for this example.
26 |
27 | NOTE: This only needs to be run once per bot, and should be ran before the example.
28 | WARNING: This will remove all other commands
29 | """
30 |
31 | from __future__ import ( # We want to use newer type hinting. If you are using python 3.9+ feel free to remove this.
32 | annotations,
33 | )
34 |
35 | import asyncio
36 | from os import environ
37 |
38 | from discord_typings import ApplicationCommandPayload
39 |
40 | from nextcore.http import BotAuthentication, HTTPClient, Route
41 |
42 | # Constants
43 | AUTHENTICATION = BotAuthentication(environ["TOKEN"])
44 | APPLICATION_ID = environ["APPLICATION_ID"] # This should also usually be the same as your bots id.
45 | COMMANDS: list[ApplicationCommandPayload] = [ # TODO: Currently we have no TypedDict for this.
46 | {"name": "ping", "description": "Responds with pong!"}
47 | ]
48 |
49 | http_client = HTTPClient()
50 |
51 |
52 | async def main() -> None:
53 | await http_client.setup()
54 |
55 | route = Route("PUT", "/applications/{application_id}/commands", application_id=APPLICATION_ID)
56 | await http_client.request(
57 | route, rate_limit_key=AUTHENTICATION.rate_limit_key, headers=AUTHENTICATION.headers, json=COMMANDS
58 | )
59 |
60 | print("Commands registered")
61 |
62 | await http_client.close()
63 |
64 |
65 | asyncio.run(main())
66 |
--------------------------------------------------------------------------------
/examples/http/get_channel/get_channel.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | #
3 | # Copyright (c) 2022-present tag-epic
4 | #
5 | # Permission is hereby granted, free of charge, to any person obtaining a
6 | # copy of this software and associated documentation files (the "Software"),
7 | # to deal in the Software without restriction, including without limitation
8 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
9 | # and/or sell copies of the Software, and to permit persons to whom the
10 | # Software is furnished to do so, subject to the following conditions:
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | import asyncio
23 | from os import environ
24 |
25 | from discord_typings import ChannelData
26 |
27 | from nextcore.http import BotAuthentication, HTTPClient, Route
28 |
29 | # Constants
30 | AUTHENTICATION = BotAuthentication(environ["TOKEN"])
31 | CHANNEL_ID = environ["CHANNEL_ID"]
32 |
33 |
34 | async def main():
35 | http_client = HTTPClient()
36 | await http_client.setup()
37 |
38 | # Documentation can be found on https://discord.dev/resources/channel#get-channel
39 |
40 | # Making Routes is almost like a f-string.
41 | # What you do is you take the route from the docs, "/channels/{channel.id}" for example
42 | # And replace . with _ and pass parameters as kwargs
43 | # Do note that you cannot replace {channel_id} directly with your channel id, that will cause issues.
44 | route = Route("GET", "/channels/{channel_id}", channel_id=CHANNEL_ID)
45 |
46 | # No authentication is used, so rate_limit_key None here is equivilent of your IP.
47 | response = await http_client.request(
48 | route,
49 | rate_limit_key=AUTHENTICATION.rate_limit_key,
50 | headers=AUTHENTICATION.headers,
51 | )
52 | channel: ChannelData = await response.json()
53 |
54 | print(channel.get("name")) # DM channels do not have names
55 |
56 | await http_client.close()
57 |
58 |
59 | asyncio.run(main())
60 |
--------------------------------------------------------------------------------
/examples/http/get_gateway/get_gateway.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | #
3 | # Copyright (c) 2022-present tag-epic
4 | #
5 | # Permission is hereby granted, free of charge, to any person obtaining a
6 | # copy of this software and associated documentation files (the "Software"),
7 | # to deal in the Software without restriction, including without limitation
8 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
9 | # and/or sell copies of the Software, and to permit persons to whom the
10 | # Software is furnished to do so, subject to the following conditions:
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | import asyncio
23 |
24 | from discord_typings import GetGatewayData
25 |
26 | from nextcore.http import HTTPClient, Route
27 |
28 |
29 | async def main():
30 | http_client = HTTPClient()
31 | await http_client.setup()
32 |
33 | # This can be found on https://discord.dev/topics/gateway#get-gateway
34 | route = Route("GET", "/gateway")
35 |
36 | # No authentication is used, so rate_limit_key None here is equivilent of your IP.
37 | response = await http_client.request(route, rate_limit_key=None)
38 | gateway: GetGatewayData = await response.json()
39 |
40 | print(gateway["url"])
41 |
42 | await http_client.close()
43 |
44 |
45 | asyncio.run(main())
46 |
--------------------------------------------------------------------------------
/newsfragments/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nextsnake/nextcore/05e3cd74e6b068256df2a73224aa5236838ba0f9/newsfragments/.gitkeep
--------------------------------------------------------------------------------
/nextcore/__init__.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from typing import TYPE_CHECKING
25 |
26 | if TYPE_CHECKING:
27 | from typing import Final
28 |
29 | __version__: Final[str] = "2.0.2"
30 | __all__: Final[tuple[str, ...]] = ("__version__",)
31 |
--------------------------------------------------------------------------------
/nextcore/common/__init__.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | """Common utility functions for the library"""
23 |
24 | from __future__ import annotations
25 |
26 | from typing import TYPE_CHECKING
27 |
28 | from .dispatcher import Dispatcher
29 | from .json import *
30 | from .maybe_coro import *
31 | from .times_per import *
32 | from .undefined import *
33 |
34 | if TYPE_CHECKING:
35 | from typing import Final
36 |
37 | __all__: Final[tuple[str, ...]] = ("Dispatcher", "json_loads", "json_dumps", "maybe_coro", "UndefinedType", "UNDEFINED")
38 |
--------------------------------------------------------------------------------
/nextcore/common/errors.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from typing import TYPE_CHECKING
25 |
26 | if TYPE_CHECKING:
27 | from typing import Final
28 |
29 | __all__: Final[tuple[str, ...]] = ("RateLimitedError",)
30 |
31 |
32 | class RateLimitedError(Exception):
33 | """A error for when a :class:`~nextcore.common.TimesPer` is rate limited and ``wait`` was :data:`False`"""
34 |
--------------------------------------------------------------------------------
/nextcore/common/json.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from typing import TYPE_CHECKING
25 |
26 | if TYPE_CHECKING:
27 | from typing import Any, Final
28 |
29 | try:
30 | import orjson # type: ignore [reportMissingImports] # orjson is optional
31 |
32 | _has_orjson: bool = True
33 | except ImportError:
34 | import json
35 |
36 | _has_orjson = False
37 |
38 |
39 | __all__: Final[tuple[str, ...]] = ("json_loads", "json_dumps")
40 |
41 | # TODO: Any should be narrowed down, however for now thats not really possible in a sane way.
42 | def json_loads(data: str) -> Any:
43 | """Loads a json string into a python object.
44 |
45 | Parameters
46 | ----------
47 | data:
48 | The json string to load.
49 |
50 | Raises
51 | ------
52 | :exc:`ValueError`
53 | The text provided is not valid json
54 | :exc:`TypeError`
55 | data must be a :class:`str`
56 | """
57 | if _has_orjson:
58 | return orjson.loads(data) # type: ignore [reportUnknownMemberType] # this will never run if it does not exist
59 | return json.loads(data)
60 |
61 |
62 | def json_dumps(to_dump: Any) -> str:
63 | """Dumps a python object into a json string.
64 |
65 | Parameters
66 | ----------
67 | to_dump:
68 | The python object to dump.
69 | """
70 | if _has_orjson:
71 | return orjson.dumps(to_dump).decode("utf-8") # type: ignore [reportUnknownMemberType] # this will never run if this does not exist
72 | return json.dumps(to_dump)
73 |
--------------------------------------------------------------------------------
/nextcore/common/maybe_coro.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from asyncio import iscoroutine
25 | from typing import TYPE_CHECKING, cast, overload
26 |
27 | if TYPE_CHECKING:
28 | from typing import Any, Callable, Coroutine, Final, TypeVar
29 |
30 | from typing_extensions import ParamSpec
31 |
32 | P = ParamSpec("P")
33 | T = TypeVar("T")
34 |
35 | __all__: Final[tuple[str, ...]] = ("maybe_coro",)
36 |
37 |
38 | @overload
39 | async def maybe_coro(coro: Callable[P, Coroutine[Any, Any, T]], *args: P.args, **kwargs: P.kwargs) -> T:
40 | ...
41 |
42 |
43 | @overload
44 | async def maybe_coro(coro: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
45 | ...
46 |
47 |
48 | async def maybe_coro(coro: Callable[P, T | Coroutine[Any, Any, T]], *args: P.args, **kwargs: P.kwargs) -> T:
49 | """Execute a sync or async function
50 |
51 | Parameters
52 | ----------
53 | coro:
54 | The function to execute
55 | args:
56 | The arguments to pass to the function
57 | kwargs:
58 | The keyword arguments to pass to the function
59 |
60 | Returns
61 | -------
62 | :data:`typing.Any`
63 | The result of the function
64 | """
65 | result = coro(*args, **kwargs)
66 |
67 | if iscoroutine(result):
68 | # coro was a async function
69 | return await result
70 |
71 | # Not a async function, just return the result
72 | result = cast("T", result) # The case where this is Coroutine[Any, Any, T] is handled by iscouroutine.
73 | return result
74 |
--------------------------------------------------------------------------------
/nextcore/common/times_per/__init__.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | """Common utility functions for the library"""
23 |
24 | from __future__ import annotations
25 |
26 | from typing import TYPE_CHECKING
27 |
28 | from .times_per import *
29 |
30 | if TYPE_CHECKING:
31 | from typing import Final
32 |
33 | __all__: Final[tuple[str, ...]] = ("TimesPer",)
34 |
--------------------------------------------------------------------------------
/nextcore/common/times_per/priority_queue_container.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from typing import TYPE_CHECKING
25 |
26 | if TYPE_CHECKING:
27 | from asyncio import Future
28 | from typing import Final
29 |
30 | __all__: Final[tuple[str, ...]] = ("PriorityQueueContainer",)
31 |
32 |
33 | class PriorityQueueContainer:
34 | """A container for times per uses for :class:`queue.PriorityQueue` to ignore the future when comparing greater than and less than
35 |
36 | Parameters
37 | ----------
38 | priority:
39 | The request priority. This will be compared!
40 | future:
41 | The future for when the request is done
42 |
43 | Attributes
44 | ----------
45 | priority:
46 | The request priority. This will be compared!
47 | future:
48 | The future for when the request is done
49 | """
50 |
51 | __slots__: tuple[str, ...] = ("priority", "future")
52 |
53 | def __init__(self, priority: int, future: Future[None]) -> None:
54 | self.priority: int = priority
55 | self.future: Future[None] = future
56 |
57 | def __gt__(self, other: PriorityQueueContainer):
58 | return self.priority > other.priority
59 |
--------------------------------------------------------------------------------
/nextcore/common/times_per/times_per.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from asyncio import CancelledError, Future, get_running_loop
25 | from contextlib import asynccontextmanager
26 | from logging import getLogger
27 | from queue import PriorityQueue
28 | from typing import TYPE_CHECKING, AsyncIterator
29 |
30 | from ..errors import RateLimitedError
31 | from .priority_queue_container import PriorityQueueContainer
32 |
33 | if TYPE_CHECKING:
34 | from typing import Final
35 |
36 | __all__: Final[tuple[str, ...]] = ("TimesPer",)
37 |
38 | logger = getLogger(__name__)
39 |
40 |
41 | class TimesPer:
42 | """A smart TimesPer implementation.
43 |
44 | Parameters
45 | ----------
46 | limit:
47 | The amount of times the rate limiter can be used
48 | per:
49 | How often this resets in seconds
50 | Attributes
51 | ----------
52 | limit:
53 | The amount of times the rate limiter can be used
54 | per:
55 | How often this resets in seconds
56 | reset_offset_seconds:
57 | How much the resetting should be offset to account for processing/networking delays.
58 |
59 | This will be added to the reset time, so for example a offset of ``1`` will make resetting 1 second slower.
60 | """
61 |
62 | __slots__ = ("limit", "per", "remaining", "reset_offset_seconds", "_pending", "_in_progress", "_pending_reset")
63 |
64 | def __init__(self, limit: int, per: float) -> None:
65 | self.limit: int = limit
66 | self.per: float = per
67 | self.remaining: int = limit
68 | self.reset_offset_seconds: float = 0
69 | self._pending: PriorityQueue[PriorityQueueContainer] = PriorityQueue()
70 | self._in_progress: int = 0
71 | self._pending_reset: bool = False
72 |
73 | @asynccontextmanager
74 | async def acquire(self, *, priority: int = 0, wait: bool = True) -> AsyncIterator[None]:
75 | """Use a spot in the rate-limit.
76 |
77 | Parameters
78 | ----------
79 | priority:
80 | The priority. **Lower** number means it will be requested earlier.
81 | wait:
82 | Wait for a spot in the rate limit.
83 |
84 | If this is :data:`False`, this will raise :exc:`RateLimitedError` instead.
85 |
86 | Raises
87 | ------
88 | RateLimitedError
89 | ``wait`` was set to :data:`False` and there was no more spots in the rate limit.
90 | Returns
91 | -------
92 | :class:`typing.AsyncContextManager`
93 | A context manager that will wait in __aenter__ until a request should be made.
94 | """
95 |
96 | calculated_remaining = self.remaining - self._in_progress
97 | logger.debug("Calculated remaining: %s", calculated_remaining)
98 | logger.debug("Reserved requests: %s", self._in_progress)
99 |
100 | if calculated_remaining == 0:
101 | if not wait:
102 | raise RateLimitedError()
103 |
104 | # Wait for a spot
105 | future: Future[None] = Future()
106 | item = PriorityQueueContainer(priority, future)
107 |
108 | self._pending.put_nowait(item)
109 |
110 | logger.debug("Added request to queue with priority %s", priority)
111 | try:
112 | await future
113 | except CancelledError:
114 | logger.debug("Cancelled .acquire, removing from queue.")
115 | self._pending.queue.remove(item)
116 | return
117 | logger.debug("Out of queue, doing request")
118 |
119 | self._in_progress += 1
120 | try:
121 | yield None
122 | except:
123 | # A exception occured. This will not take from the rate-limit, and as so we have to re-allow a request to run
124 | if self._pending.qsize() != 0:
125 | container = self._pending.get_nowait()
126 | future = container.future
127 |
128 | # Mark it as completed, good practice (and saves a bit of memory due to a infinitly expanding int)
129 | self._pending.task_done()
130 |
131 | # Release it and allow further requests
132 | future.set_result(None)
133 |
134 | raise # Re-raise exception
135 | finally:
136 | # Start a reset task
137 | if not self._pending_reset:
138 | self._pending_reset = True
139 | loop = get_running_loop()
140 | loop.call_later(self.per + self.reset_offset_seconds, self._reset)
141 |
142 | self._in_progress -= 1
143 |
144 | # This only gets called if no exception got raised.
145 | self.remaining -= 1
146 |
147 | def _reset(self) -> None:
148 | self._pending_reset = False
149 |
150 | self.remaining = self.limit
151 |
152 | to_release = min(self._pending.qsize(), self.remaining - self._in_progress)
153 | logger.debug("Releasing %s requests", to_release)
154 | for _ in range(to_release):
155 | container = self._pending.get_nowait()
156 | future = container.future
157 |
158 | # Mark it as completed, good practice (and saves a bit of memory due to a infinitly expanding int)
159 | self._pending.task_done()
160 |
161 | # Release it and allow further requests
162 | future.set_result(None)
163 | if self._pending.qsize():
164 | self._pending_reset = True
165 |
166 | loop = get_running_loop()
167 | loop.call_later(self.per + self.reset_offset_seconds, self._reset)
168 |
169 | async def close(self) -> None:
170 | """Cleanup this instance.
171 |
172 | This should be done when this instance is never going to be used anymore
173 |
174 | .. warning::
175 | Continued use of this instance will result in instability
176 | """
177 | # No need to clear this, as the .acquire function does it for us.
178 | for request in self._pending.queue:
179 | request.future.set_exception(CancelledError)
180 |
--------------------------------------------------------------------------------
/nextcore/common/undefined.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from enum import Enum
25 | from typing import TYPE_CHECKING
26 |
27 | if TYPE_CHECKING:
28 | from typing import Final, Literal
29 |
30 | __all__: Final[tuple[str, ...]] = ("UndefinedType", "UNDEFINED")
31 |
32 |
33 | class UndefinedType(Enum):
34 | """A second :data:`None` for specifying that it should not be provided.
35 |
36 |
37 | **Example usage:**
38 |
39 | .. code-block:: python3
40 | :emphasize-lines: 4,5
41 |
42 | from nextcore.common import UndefinedType, UNDEFINED
43 | thing = UNDEFINED
44 |
45 | if thing is UNDEFINED:
46 | print("Thing is undefined!")
47 | """
48 |
49 | UNDEFINED = None
50 |
51 |
52 | UNDEFINED: Literal[UndefinedType.UNDEFINED] = UndefinedType.UNDEFINED
53 |
--------------------------------------------------------------------------------
/nextcore/gateway/__init__.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | """Wrapper around the Discord bot gateway
23 |
24 | This module allows you to receive events from Discord.
25 | """
26 |
27 | from __future__ import annotations
28 |
29 | from typing import TYPE_CHECKING
30 |
31 | if TYPE_CHECKING:
32 | from typing import Final
33 |
34 | from .decompressor import Decompressor
35 | from .errors import *
36 | from .op_code import GatewayOpcode
37 | from .shard import Shard
38 | from .shard_manager import ShardManager
39 |
40 | __all__: Final[tuple[str, ...]] = (
41 | "ShardManager",
42 | "Shard",
43 | "Decompressor",
44 | "GatewayOpcode",
45 | "ReconnectCheckFailedError",
46 | "DisconnectError",
47 | "InvalidIntentsError",
48 | "DisallowedIntentsError",
49 | "InvalidTokenError",
50 | "InvalidApiVersionError",
51 | "InvalidShardCountError",
52 | "UnhandledCloseCodeError",
53 | )
54 |
--------------------------------------------------------------------------------
/nextcore/gateway/close_code.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from enum import IntEnum
25 | from typing import TYPE_CHECKING
26 |
27 | if TYPE_CHECKING:
28 | from typing import Final
29 |
30 | __all__: Final[tuple[str, ...]] = ("GatewayCloseCode",)
31 |
32 |
33 | class GatewayCloseCode(IntEnum):
34 | """A gateway close code.
35 |
36 | .. note::
37 | Read the `documentation `_
38 | """
39 |
40 | UNKNOWN_ERROR = 4000
41 | """An unknown error occurred."""
42 | UNKNOWN_OPCODE = 4001
43 | """We sent a invalid gateway opcode."""
44 | DECODE_ERROR = 4002
45 | """We sent a invalid payload."""
46 | NOT_AUTHENTICATED = 4003
47 | """We sent a payload before we were authenticated."""
48 | AUTHENTICATION_FAILED = 4004
49 | """We sent a invalid token."""
50 | ALREADY_AUTHENTICATED = 4005
51 | """We tried to authenticate more than once."""
52 | INVALID_SEQUENCE = 4007
53 | """We tried to resume with a invalid sequence id."""
54 | RATE_LIMITED = 4008
55 | """We sent payloads too fast."""
56 | SESSION_TIMEOUT = 4009
57 | """Your session timed out."""
58 | INVALID_SHARD = 4010
59 | """We sent an invalid shard id or our shard count is too low."""
60 | SHARDING_REQUIRED = 4011
61 | """Sharding is required to continue."""
62 | INVALID_API_VERSION = 4012
63 | """We sent an invalid api version."""
64 | INVALID_INTENTS = 4013
65 | """We sent invalid intents."""
66 | DISALLOWED_INTENTS = 4014
67 | """We sent intents that are not allowed to use."""
68 |
--------------------------------------------------------------------------------
/nextcore/gateway/decompressor.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from typing import TYPE_CHECKING
25 | from zlib import decompressobj
26 | from zlib import error as zlib_error
27 |
28 | if TYPE_CHECKING:
29 | from typing import ClassVar, Final
30 |
31 | __all__: Final[tuple[str, ...]] = ("Decompressor",)
32 |
33 |
34 | class Decompressor:
35 | """A wrapper around zlib to handle partial payloads
36 |
37 | **Example usage**
38 |
39 | .. code-block::
40 |
41 | from nextcore.gateway import Decompressor
42 |
43 | decompressor = Decompressor()
44 |
45 | data = decompressor.decompress(zlib_data) # bytes from the Discord gateway
46 |
47 | print(data.decode("utf-8"))
48 | """
49 |
50 | ZLIB_SUFFIX: ClassVar[bytes] = b"\x00\x00\xff\xff"
51 |
52 | __slots__ = ("_decompressor", "_buffer")
53 |
54 | def __init__(self) -> None:
55 | self._decompressor = decompressobj()
56 | self._buffer: bytearray = bytearray()
57 |
58 | def decompress(self, data: bytes) -> bytes | None:
59 | """Decompress zlib data.
60 |
61 | **Example usage:**
62 |
63 | .. code-block::
64 | :emphasize-lines: 5
65 |
66 | from nextcore.gateway import Decompressor
67 |
68 | decompressor = Decompressor()
69 |
70 | data = decompressor.decompress(zlib_data) # bytes from the Discord gateway
71 |
72 | print(data.decode("utf-8"))
73 |
74 |
75 | Parameters
76 | ----------
77 | data:
78 | The zlib compressed bytes.
79 |
80 | Returns
81 | -------
82 | :class:`bytes` | :data:`None`:
83 | The decompressed data.
84 |
85 | This is :data:`None` if this is a partial payload.
86 |
87 | Raises
88 | ------
89 | :exc:`ValueError`:
90 | This is not zlib compressed data. The data could also be corrupted.
91 | """
92 | self._buffer.extend(data)
93 |
94 | if len(data) < 4 or data[-4:] != Decompressor.ZLIB_SUFFIX:
95 | # Not a full payload, try again next time
96 | return None
97 |
98 | try:
99 | data = self._decompressor.decompress(data)
100 | except zlib_error:
101 | raise ValueError("Data is corrupted. Please void all zlib context.") from None
102 |
103 | # If successful, clear the pending data.
104 | self._buffer.clear()
105 | return data
106 |
--------------------------------------------------------------------------------
/nextcore/gateway/errors.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from typing import TYPE_CHECKING
25 |
26 | if TYPE_CHECKING:
27 | from typing import Final
28 |
29 | __all__: Final[tuple[str, ...]] = (
30 | "ReconnectCheckFailedError",
31 | "DisconnectError",
32 | "InvalidIntentsError",
33 | "DisallowedIntentsError",
34 | "InvalidTokenError",
35 | "InvalidApiVersionError",
36 | "InvalidShardCountError",
37 | "UnhandledCloseCodeError",
38 | )
39 |
40 |
41 | class ReconnectCheckFailedError(Exception):
42 | """Error for when auto reconnect is set to False and the shard needs to IDENTIFY"""
43 |
44 | def __init__(self) -> None:
45 | super().__init__('Reconnect check failed. This shard should be considered "dead".')
46 |
47 |
48 | class DisconnectError(Exception):
49 | """A unexpected disconnect from the gateway happened."""
50 |
51 |
52 | class InvalidIntentsError(DisconnectError):
53 | """The intents provided are invalid."""
54 |
55 | def __init__(self) -> None:
56 | super().__init__("The intents provided are invalid.")
57 |
58 |
59 | class DisallowedIntentsError(DisconnectError):
60 | """The intents provided are disallowed."""
61 |
62 | def __init__(self) -> None:
63 | super().__init__(
64 | "You can't use intents you are not allowed to use. Enable them in the switches in the developer portal or apply for them."
65 | )
66 |
67 |
68 | class InvalidTokenError(DisconnectError):
69 | """The token provided is invalid."""
70 |
71 | def __init__(self) -> None:
72 | super().__init__("The token provided is invalid.")
73 |
74 |
75 | class InvalidApiVersionError(DisconnectError):
76 | """The api version provided is invalid."""
77 |
78 | def __init__(self) -> None:
79 | super().__init__("The api version provided is invalid. This can probably be fixed by updating!")
80 |
81 |
82 | class InvalidShardCountError(DisconnectError):
83 | """The shard count provided is invalid."""
84 |
85 | def __init__(self) -> None:
86 | super().__init__(
87 | "The shard count provided is invalid."
88 | "This can be due to specifying a shard_id larger than shard_count or that the bot needs more shards to connect."
89 | )
90 |
91 |
92 | class UnhandledCloseCodeError(DisconnectError):
93 | """The close code provided is unknown to the library and as such it cannot be handled properly.
94 |
95 | Parameters
96 | ----------
97 | code: :class:`int`
98 | The close code.
99 |
100 | Attributes
101 | ----------
102 | code: :class:`int`
103 | The close code.
104 | """
105 |
106 | def __init__(self, code: int) -> None:
107 | self.code: int = code
108 | super().__init__(f"The close code provided is unhandled: {code}")
109 |
--------------------------------------------------------------------------------
/nextcore/gateway/exponential_backoff.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from asyncio import sleep
25 | from logging import getLogger
26 | from typing import TYPE_CHECKING
27 |
28 | if TYPE_CHECKING:
29 | from typing import Final
30 |
31 | from typing_extensions import Self
32 |
33 |
34 | logger = getLogger(__name__)
35 |
36 | __all__: Final[tuple[str, ...]] = ("ExponentialBackoff",)
37 |
38 |
39 | class ExponentialBackoff:
40 | """A implementation of exponential backoff
41 |
42 | Parameters
43 | ----------
44 | initial:
45 | The initial value of the backoff
46 | base:
47 | What to multiply the current time with when the next iteration of backoff is called
48 | max_value:
49 | The max value to cap the backoff at
50 |
51 | Attributes
52 | ----------
53 | base: :class:`float`
54 | What to multiply the current time with when the next iteration of backoff is called
55 | max: :class:`float`
56 | The max value to cap the backoff at
57 | """
58 |
59 | __slots__ = ("_current_time", "base", "max", "_initial")
60 |
61 | def __init__(self, initial: float, base: float, max_value: float) -> None:
62 | self._current_time: float = initial
63 | self.base: float = base
64 | self.max: float = max_value
65 | self._initial: bool = True
66 |
67 | @property
68 | def next(self) -> float:
69 | """What the next value of the backoff should be"""
70 | return self._current_time * self.base
71 |
72 | # TODO: MyPy does not support typing_extensions.Self yet?
73 | def __aiter__(self) -> Self: # type: ignore [valid-type]
74 | return self
75 |
76 | async def __anext__(self) -> None:
77 | if not self._initial:
78 | self._current_time = min(self.max, self.next)
79 | logger.debug("Sleeping for %s seconds", self._current_time)
80 | await sleep(self._current_time)
81 | else:
82 | self._initial = False
83 |
--------------------------------------------------------------------------------
/nextcore/gateway/op_code.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 | from __future__ import annotations
22 |
23 | from enum import IntEnum
24 | from typing import TYPE_CHECKING
25 |
26 | if TYPE_CHECKING:
27 | from typing import Final
28 |
29 | __all__: Final[tuple[str, ...]] = ("GatewayOpcode",)
30 |
31 |
32 | class GatewayOpcode(IntEnum):
33 | """Enum of all opcodes that can be sent/received to/from the gateway."""
34 |
35 | DISPATCH = 0
36 | """Can be received"""
37 | HEARTBEAT = 1
38 | """Can be sent/received"""
39 | IDENTIFY = 2
40 | """Can be sent"""
41 | PRESENCE_UPDATE = 3
42 | """Can be sent"""
43 | VOICE_STATE_UPDATE = 4
44 | """Can be sent"""
45 | RESUME = 6
46 | """Can be sent"""
47 | RECONNECT = 7
48 | """Can be received"""
49 | REQUEST_GUILD_MEMBERS = 8
50 | """Can be sent"""
51 | INVALID_SESSION = 9
52 | """Can be received"""
53 | HELLO = 10
54 | """Can be received"""
55 | HEARTBEAT_ACK = 11
56 | """Can be received"""
57 |
--------------------------------------------------------------------------------
/nextcore/gateway/shard_manager.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from asyncio import CancelledError, gather, get_running_loop
25 | from collections import defaultdict
26 | from logging import getLogger
27 | from typing import TYPE_CHECKING
28 |
29 | from aiohttp import ClientConnectionError
30 |
31 | from ..common import Dispatcher, TimesPer
32 | from ..http import Route
33 | from .errors import InvalidShardCountError
34 | from .exponential_backoff import ExponentialBackoff
35 | from .shard import Shard
36 |
37 | if TYPE_CHECKING:
38 | from typing import Any, Coroutine, Final
39 |
40 | from discord_typings import GatewayEvent, GetGatewayBotData, UpdatePresenceData
41 |
42 | from ..http import BotAuthentication, HTTPClient
43 |
44 | __all__: Final[tuple[str, ...]] = ("ShardManager",)
45 |
46 | logger = getLogger(__name__)
47 |
48 |
49 | class ShardManager:
50 | """A automatic sharder implementation
51 |
52 | Parameters
53 | ----------
54 | authentication:
55 | Authentication info.
56 | intents:
57 | The intents the bot should connect with. See the `documentation `__.
58 | http_client:
59 | The HTTP client to use for fetching info to connect to the gateway.
60 | shard_count:
61 | The amount of shards the bot should spawn. If this is not set, the bot will automatically decide and keep the shard count up to date.
62 | shard_ids:
63 | The shard ids the bot should spawn. If this is not set, the bot will use all shard ids possible with the :attr:`ShardManager.shard_count`. This requires :attr:`ShardManager.shard_count` to be set.
64 | presence:
65 | The initial presence the bot should connect with.
66 |
67 | Attributes
68 | ----------
69 | token:
70 | The bot's token
71 | intents:
72 | The intents the bot should connect with. See the `documentation `__.
73 | shard_count:
74 | The amount of shards the bot should spawn. If this is not set, the bot will automatically decide and keep the shard count up to date.
75 | shard_ids:
76 | The shard ids the bot should spawn. If this is not set, the bot will use all shard ids possible with the :attr:`ShardManager.shard_count`. This requires :attr:`ShardManager.shard_count` to be set.
77 | presence:
78 | The initial presence the bot should connect with.
79 | active_shards:
80 | A list of all shards that are currently connected.
81 | raw_dispatcher:
82 | A dispatcher with raw payloads sent by discord. The event name is the opcode, and the value is the raw data.
83 | event_dispatcher:
84 | A dispatcher for DISPATCH events sent by discord. The event name is the event name, and the value is the inner payload.
85 | max_concurrency:
86 | The maximum amount of concurrent IDENTIFY's the bot can make.
87 | """
88 |
89 | __slots__ = (
90 | "authentication",
91 | "intents",
92 | "shard_count",
93 | "shard_ids",
94 | "presence",
95 | "active_shards",
96 | "pending_shards",
97 | "raw_dispatcher",
98 | "event_dispatcher",
99 | "dispatcher",
100 | "max_concurrency",
101 | "_active_shard_count",
102 | "_pending_shard_count",
103 | "_identify_rate_limits",
104 | "_http_client",
105 | )
106 |
107 | def __init__(
108 | self,
109 | authentication: BotAuthentication,
110 | intents: int,
111 | http_client: HTTPClient,
112 | *,
113 | shard_count: int | None = None,
114 | shard_ids: list[int] | None = None,
115 | presence: UpdatePresenceData | None = None,
116 | ) -> None:
117 | # User's params
118 | self.authentication: BotAuthentication = authentication
119 | self.intents: int = intents
120 | self.shard_count: Final[int | None] = shard_count
121 | self.shard_ids: Final[list[int] | None] = shard_ids
122 | self.presence: UpdatePresenceData | None = presence
123 |
124 | # Publics
125 | self.active_shards: list[Shard] = []
126 | self.pending_shards: list[Shard] = []
127 | self.raw_dispatcher: Dispatcher[int] = Dispatcher()
128 | self.event_dispatcher: Dispatcher[str] = Dispatcher()
129 | self.dispatcher: Dispatcher[str] = Dispatcher()
130 | self.max_concurrency: int | None = None
131 |
132 | # Privates
133 | self._active_shard_count: int | None = self.shard_count
134 | self._pending_shard_count: int | None = None
135 | self._identify_rate_limits: defaultdict[int, TimesPer] = defaultdict(lambda: TimesPer(1, 5))
136 | self._http_client: HTTPClient = http_client
137 |
138 | # Checks
139 | if shard_count is None and shard_ids is not None:
140 | raise ValueError("You have to specify shard_count if you specify shard_ids")
141 |
142 | async def connect(self) -> None:
143 | """Connect all the shards to the gateway.
144 |
145 | .. note::
146 | This will return once all shards have started connecting.
147 | .. note::
148 | This will do a request to ``GET /gateway/bot``
149 |
150 | Raises
151 | ------
152 | :exc:`RuntimeError`
153 | Already connected.
154 | """
155 | if self.active_shards:
156 | raise RuntimeError("Already connected!")
157 |
158 | # Get max concurrency and recommended shard count
159 | # Exponential backoff for get_gateway_bot
160 | route = Route("GET", "/gateway/bot")
161 | async for _ in ExponentialBackoff(0.5, 2, 10):
162 | try:
163 | response = await self._http_client.request(
164 | route,
165 | rate_limit_key=self.authentication.rate_limit_key,
166 | headers=self.authentication.headers,
167 | )
168 | connection_info = await response.json()
169 | except ClientConnectionError:
170 | logger.exception("Failed to connect to the gateway? Check your internet connection")
171 | else:
172 | break
173 |
174 | # This is marked as possibly unbound because the ExponentialBackoff iterator might end. This will never happen
175 | # TODO: Rewrite ExponentialBackoff to use await .wait() instead.
176 | connection_info = connection_info # type: ignore [reportUnboundVariable]
177 |
178 | session_start_limits = connection_info["session_start_limit"]
179 | self.max_concurrency = session_start_limits["max_concurrency"]
180 |
181 | if self._active_shard_count is None:
182 | # No shard count provided, use the recommended amount by Discord.
183 | self._active_shard_count = connection_info["shards"]
184 |
185 | if self.shard_ids is None:
186 | # Use all shards if shard_ids is not specified
187 | shard_ids = list(range(self._active_shard_count))
188 | else:
189 | shard_ids = self.shard_ids
190 |
191 | for shard_id in shard_ids:
192 | shard = self._spawn_shard(shard_id, self._active_shard_count)
193 |
194 | # Register event listeners
195 | shard.raw_dispatcher.add_listener(self._on_raw_shard_receive)
196 | shard.event_dispatcher.add_listener(self._on_shard_dispatch)
197 | shard.dispatcher.add_listener(self._on_shard_critical, "critical")
198 |
199 | logger.info("Added shard event listeners")
200 |
201 | self.active_shards.append(shard)
202 |
203 | def _spawn_shard(self, shard_id: int, shard_count: int) -> Shard:
204 | assert self.max_concurrency is not None, "max_concurrency is not set. This is set in connect"
205 | rate_limiter = self._identify_rate_limits[shard_id % self.max_concurrency]
206 |
207 | shard = Shard(
208 | shard_id,
209 | shard_count,
210 | self.intents,
211 | self.authentication.token,
212 | rate_limiter,
213 | self._http_client,
214 | presence=self.presence,
215 | )
216 |
217 | # Here we lazy connect the shard. This gives us a bit more speed when connecting large sets of shards.
218 | loop = get_running_loop()
219 | loop.create_task(shard.connect())
220 |
221 | return shard
222 |
223 | async def rescale_shards(self, shard_count: int, shard_ids: list[int] | None = None) -> None:
224 | """Change the shard count without restarting
225 |
226 | This slowly changes the shard count.
227 |
228 | .. warning::
229 | You can only change the shard count once at a time
230 |
231 | Parameters
232 | ----------
233 | shard_count:
234 | The shard count to change to.
235 | shard_ids:
236 | Shards to start. If it is set to :data:`None`, this will use all shards up to the shard count.
237 |
238 | Raises
239 | ------
240 | RuntimeError
241 | You can only run rescale_shards once at a time.
242 | RuntimeError
243 | You need to use :meth:`ShardManager.connect` first.
244 | """
245 | if self._pending_shard_count is not None:
246 | raise RuntimeError("rescale_shards can only be ran once at a time")
247 | if self.max_concurrency is None:
248 | raise RuntimeError("You need to use ShardManager.connect first")
249 |
250 | # Set properties
251 | self._pending_shard_count = shard_count
252 | self.pending_shards.clear()
253 |
254 | # Get the shard ids
255 | if shard_ids is None:
256 | shard_ids = list(range(shard_count))
257 |
258 | shard_connects: list[Coroutine[Any, Any, None]] = []
259 |
260 | for shard_id in shard_ids:
261 | rate_limiter = self._identify_rate_limits[shard_id % self.max_concurrency]
262 |
263 | shard = Shard(
264 | shard_id,
265 | shard_count,
266 | self.intents,
267 | self.authentication.token,
268 | rate_limiter,
269 | self._http_client,
270 | presence=self.presence,
271 | )
272 |
273 | shard_connects.append(shard.connect())
274 |
275 | self.pending_shards.append(shard)
276 | try:
277 | await gather(*shard_connects)
278 | except CancelledError:
279 | logger.info("Shard re-scale was cancelled!")
280 |
281 | # Reset all pending info
282 | # Close all shards
283 | await gather(*[shard.close() for shard in self.pending_shards])
284 |
285 | self.pending_shards.clear()
286 | self._pending_shard_count = None
287 | return
288 |
289 | logger.info("Shard re-scaling finished!")
290 |
291 | # Replace current shard set with the pending shard set
292 |
293 | # Close all active shards
294 | await gather(*[shard.close() for shard in self.active_shards])
295 |
296 | # Register event listeners
297 | for shard in self.pending_shards:
298 | shard.raw_dispatcher.add_listener(self._on_raw_shard_receive)
299 | shard.event_dispatcher.add_listener(self._on_shard_dispatch)
300 | shard.dispatcher.add_listener(self._on_shard_critical, "critical")
301 |
302 | # Replace the shard set
303 | self.active_shards.clear()
304 | self.active_shards = self.pending_shards
305 | self._active_shard_count = shard_count
306 |
307 | # Cleanup
308 | self.pending_shards = []
309 | self._pending_shard_count = None
310 |
311 | # Handlers
312 | async def _on_raw_shard_receive(self, opcode: int, data: GatewayEvent) -> None:
313 | logger.debug("Relaying raw event")
314 | await self.raw_dispatcher.dispatch(opcode, data)
315 |
316 | async def _on_shard_dispatch(self, event_name: str, data: Any) -> None:
317 | logger.debug("Relaying event")
318 | await self.event_dispatcher.dispatch(event_name, data)
319 |
320 | async def _on_shard_critical(self, error: Exception):
321 | if isinstance(error, InvalidShardCountError):
322 | if self.shard_count is not None:
323 | await self.dispatcher.dispatch("critical", InvalidShardCountError())
324 | return
325 |
326 | if self._pending_shard_count:
327 | # Already re-scaling to a (hopefully) proper shard count.
328 | # To avoid duplication, we avoid calling it multiple times
329 | logger.debug("Already re-scaling, ignoring invalid shard count")
330 | return
331 |
332 | logger.info("Automatically re-scaling due to too few shards!")
333 |
334 | route = Route("GET", "/gateway/bot")
335 |
336 | response = await self._http_client.request(
337 | route,
338 | rate_limit_key=self.authentication.rate_limit_key,
339 | headers=self.authentication.headers,
340 | )
341 |
342 | gateway: GetGatewayBotData = await response.json()
343 | recommended_shard_count = gateway["shards"]
344 |
345 | await self.rescale_shards(recommended_shard_count)
346 |
347 | await self.dispatcher.dispatch("critical", error)
348 |
349 | await self.close()
350 |
351 | async def close(self) -> None:
352 | logger.debug("Closing shards")
353 | for shard in self.active_shards:
354 | await shard.close()
355 | for shard in self.pending_shards:
356 | await shard.close()
357 |
--------------------------------------------------------------------------------
/nextcore/http/__init__.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | """Do requests to Discord over the HTTP API.
23 |
24 | This module includes a HTTP client that handles rate limits for you,
25 | and gives you convinient methods around the API.
26 | """
27 |
28 | from .authentication import *
29 | from .bucket import *
30 | from .bucket_metadata import *
31 | from .client import *
32 | from .errors import *
33 | from .global_rate_limiter import *
34 | from .rate_limit_storage import *
35 | from .request_session import *
36 | from .route import *
37 |
--------------------------------------------------------------------------------
/nextcore/http/authentication/__init__.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from typing import TYPE_CHECKING
25 |
26 | from .base import BaseAuthentication
27 | from .bearer import BearerAuthentication
28 | from .bot import BotAuthentication
29 |
30 | if TYPE_CHECKING:
31 | from typing import Final
32 |
33 | __all__: Final[tuple[str, ...]] = ("BaseAuthentication", "BotAuthentication", "BearerAuthentication")
34 |
--------------------------------------------------------------------------------
/nextcore/http/authentication/base.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from abc import ABC, abstractmethod
25 | from typing import TYPE_CHECKING
26 |
27 | if TYPE_CHECKING:
28 | from typing import Final
29 |
30 | __all__: Final[tuple[str, ...]] = ("BaseAuthentication",)
31 |
32 |
33 | class BaseAuthentication(ABC):
34 | """A wrapper around discord credentials.
35 |
36 | .. warning::
37 | This is a base class. You should probably use :class:`BotAuthentication` or :class:`BearerAuthentication` instead.
38 |
39 | **Example implementation**
40 |
41 | .. this is relative to the docs directory
42 | .. literalinclude:: ../nextcore/http/authentication/bot.py
43 | :language: python3
44 | :lines: 34-
45 |
46 | Attributes
47 | ----------
48 | prefix:
49 | The prefix of the authentication.
50 | token:
51 | The bot's token.
52 | """
53 |
54 | __slots__ = ("prefix", "token")
55 |
56 | @property
57 | @abstractmethod
58 | def rate_limit_key(self) -> str | None:
59 | """The key used for rate limiting
60 |
61 | This is usually the prefix + token for for example ``Bot AABBCC.DDEEFF.GGHHII``
62 |
63 | **Example usage**
64 |
65 | .. code-block:: python3
66 |
67 | await http_client.request(route, rate_limit_key=authentication.rate_limit_key, ...)
68 | """
69 | ...
70 |
71 | @property
72 | @abstractmethod
73 | def headers(self) -> dict[str, str]:
74 | """Headers used for making a authenticated request.
75 |
76 | This may return a empty dict if headers is not used for authenticating this type of authentication.
77 |
78 | **Example usage**
79 |
80 | .. code-block:: python3
81 |
82 | await http_client.request(route, rate_limit_key=authentication.rate_limit_key, headers=authentication.headers, ...)
83 |
84 | """
85 | ...
86 |
--------------------------------------------------------------------------------
/nextcore/http/authentication/bearer.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from typing import TYPE_CHECKING
25 |
26 | from .base import BaseAuthentication
27 |
28 | if TYPE_CHECKING:
29 | from typing import Literal
30 |
31 | __all__ = ("BearerAuthentication",)
32 |
33 |
34 | class BearerAuthentication(BaseAuthentication):
35 | """A wrapper around OAuth2 Bearer Token authentication.
36 |
37 | Parameters
38 | ----------
39 | token:
40 | The bearer token.
41 |
42 | Attributes
43 | ----------
44 | prefix:
45 | The prefix of the token.
46 | token:
47 | The bearer token
48 | """
49 |
50 | __slots__: tuple[str, ...] = ()
51 |
52 | def __init__(self, token: str) -> None:
53 | self.prefix: Literal["Bearer"] = "Bearer"
54 | self.token: str = token
55 |
56 | @property
57 | def rate_limit_key(self) -> str:
58 | """The key used for rate limiting
59 |
60 | This will be in the format ``Bearer AABBCCDDEEFFGGHHII``
61 |
62 | **Example usage**
63 |
64 | .. code-block:: python3
65 |
66 | await http_client.request(route, rate_limit_key=authentication.rate_limit_key, headers=authentication.headers, ...)
67 | """
68 | return f"{self.prefix} {self.token}"
69 |
70 | @property
71 | def headers(self) -> dict[str, str]:
72 | """Headers for doing a authenticated request.
73 |
74 | This will return a dict with a ``Authorization`` field.
75 |
76 | **Example usage**
77 |
78 | .. code-block:: python3
79 |
80 | await http_client.request(route, rate_limit_key=authentication.rate_limit_key, headers=authentication.headers, ...)
81 | """
82 | return {"Authorization": f"{self.prefix} {self.token}"}
83 |
--------------------------------------------------------------------------------
/nextcore/http/authentication/bot.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from typing import TYPE_CHECKING
25 |
26 | from .base import BaseAuthentication
27 |
28 | if TYPE_CHECKING:
29 | from typing import Final, Literal
30 |
31 | __all__: Final[tuple[str, ...]] = ("BotAuthentication",)
32 |
33 |
34 | class BotAuthentication(BaseAuthentication):
35 | """A wrapper around bot token authentication.
36 |
37 | **Example usage**
38 |
39 | .. code-block:: python3
40 |
41 | authentication = BotAuthentication(os.environ["TOKEN"])
42 |
43 | route = Route("GET", "/gateway/bot")
44 | await http_client.request(route, rate_limit_key=authentication.rate_limit_key, headers=authentication.headers)
45 |
46 | Parameters
47 | ----------
48 | token:
49 | The bot token.
50 |
51 | Attributes
52 | ----------
53 | prefix:
54 | The prefix of the token.
55 | token:
56 | The bot token
57 | """
58 |
59 | __slots__: tuple[str, ...] = ()
60 |
61 | def __init__(self, token: str) -> None:
62 | self.prefix: Literal["Bot"] = "Bot"
63 | self.token: str = token
64 |
65 | @property
66 | def rate_limit_key(self) -> str:
67 | """The key used for rate limiting
68 |
69 | This will be in the format ``Bot AABBCC.DDEEFF.GGHHII``
70 |
71 | **Example usage**
72 |
73 | .. code-block:: python3
74 |
75 | await http_client.request(route, rate_limit_key=authentication.rate_limit_key, headers=authentication.headers, ...)
76 | """
77 | return f"{self.prefix} {self.token}"
78 |
79 | @property
80 | def headers(self) -> dict[str, str]:
81 | """Headers for doing a authenticated request.
82 |
83 | This will return a dict with a ``Authorization`` field.
84 |
85 | **Example usage**
86 |
87 | .. code-block:: python3
88 |
89 | await http_client.request(route, rate_limit_key=authentication.rate_limit_key, headers=authentication.headers, ...)
90 | """
91 |
92 | return {"Authorization": f"{self.prefix} {self.token}"}
93 |
--------------------------------------------------------------------------------
/nextcore/http/bucket.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from asyncio import CancelledError, Event, get_running_loop
25 | from contextlib import asynccontextmanager
26 | from logging import getLogger
27 | from queue import PriorityQueue
28 | from typing import TYPE_CHECKING, cast, overload
29 |
30 | from nextcore.common.errors import RateLimitedError
31 |
32 | from .request_session import RequestSession
33 |
34 | if TYPE_CHECKING:
35 | from typing import AsyncIterator, Final, Literal
36 |
37 | from .bucket_metadata import BucketMetadata
38 |
39 | logger = getLogger(__name__)
40 |
41 | __all__: Final[tuple[str, ...]] = ("Bucket",)
42 |
43 |
44 | class Bucket:
45 | """A discord rate limit implementation around a bucket.
46 |
47 | **Example usage**
48 |
49 | .. code-block:: python3
50 |
51 | bucket_metadata = BucketMetadata()
52 | bucket = Bucket(bucket_metadata)
53 |
54 | async with bucket.acquire():
55 | # Do request
56 | await bucket.update(remaining, reset_after_seconds, unlimited=False)
57 |
58 | Parameters
59 | ----------
60 | metadata:
61 | The metadata for the bucket.
62 |
63 | Attributes
64 | ----------
65 | metadata:
66 | The metadata for the bucket.
67 |
68 | This should also be updated with info that applies to all buckets like limit and if it is unlimited.
69 | reset_offset_seconds:
70 | How much the resetting should be offset to account for processing/networking delays.
71 |
72 | This will be added to the reset time, so for example a offset of ``1`` will make resetting 1 second slower.
73 | """
74 |
75 | __slots__ = (
76 | "metadata",
77 | "reset_offset_seconds",
78 | "_remaining",
79 | "_pending",
80 | "_reserved",
81 | "_resetting",
82 | "_can_do_blind_request",
83 | "__weakref__",
84 | )
85 |
86 | def __init__(self, metadata: BucketMetadata):
87 | self.metadata: BucketMetadata = metadata
88 | self.reset_offset_seconds: float = 0
89 | self._remaining: int | None = None # None signifies unlimited or not used yet (due to a optimization)
90 | self._pending: PriorityQueue[RequestSession] = PriorityQueue()
91 | self._reserved: list[RequestSession] = []
92 | self._resetting: bool = False
93 | self._can_do_blind_request: Event = Event()
94 |
95 | self._can_do_blind_request.set()
96 |
97 | @asynccontextmanager
98 | async def acquire(self, *, priority: int = 0, wait: bool = True) -> AsyncIterator[None]:
99 | """Use a spot in the rate limit.
100 |
101 | **Example usage**
102 |
103 | .. code-block:: python3
104 |
105 | async with bucket.acquire():
106 | # Do request
107 | await bucket.update(remaining, reset_after_seconds, unlimited=False)
108 |
109 | Parameters
110 | ----------
111 | priority:
112 | The priority of a request. A lower number means it will be executed faster.
113 | wait:
114 | Wait for a spot in the rate limit.
115 |
116 | If this is set to :data:`False`, this will raise :exc:`RateLimitedError` if no spot is available right now.
117 |
118 | Raises
119 | ------
120 | RateLimitedError
121 | You are rate limited and ``wait`` was set to :data:`False`
122 | """
123 | if self.metadata.unlimited:
124 | # Instantly return and avoid touching any of the state.
125 | yield
126 | return
127 |
128 | if self._remaining is None and self.metadata.limit is not None:
129 | # We have info from metadata! Use that
130 | self._remaining = self.metadata.limit
131 |
132 | if self._remaining is not None:
133 | # Already using this bucket
134 |
135 | session = RequestSession(priority=priority)
136 |
137 | estimated_remaining = self._remaining - len(
138 | self._reserved
139 | ) # We assume every request is successful, and retry when that is not the case.
140 |
141 | if estimated_remaining == 0:
142 | if not wait:
143 | raise RateLimitedError()
144 | self._pending.put_nowait(
145 | session
146 | ) # This can't raise a exception as pending is always infinite unless someone else modified it
147 | await session.pending_future # Wait for a spot in the rate limit.
148 | # This will automatically be removed by the waker.
149 |
150 | self._reserved.append(session)
151 | try:
152 | yield # Let the user do the request
153 | except:
154 | # Release one request as we assume the request failed.
155 | if self._pending.qsize() >= 1:
156 | self._release_pending(1)
157 |
158 | raise # Re-raise the exception
159 | finally:
160 | self._reserved.remove(session)
161 | return
162 |
163 | # We have no info on rate limits, so we have to do a "blind" request to find out what the rate limits is.
164 | # We will only do one "blind" request at a time per bucket though in case the rate limit is small.
165 | # This could be tweaked to use more on routes with higher rate limits, however this would require hard coding which is not a thing I want
166 | # for nextcore.
167 | session = RequestSession(priority=priority)
168 |
169 | if self._can_do_blind_request.is_set():
170 | self._can_do_blind_request.clear()
171 |
172 | self._reserved.append(session)
173 | try:
174 | yield # Let the user do the request
175 | except:
176 | # Release one request as we assume the request failed.
177 | if self._pending.qsize() >= 1:
178 | self._release_pending(1)
179 |
180 | raise # Re-raise the exception
181 | else:
182 | if self._remaining is None:
183 | logger.warning("A user of Bucket is not calling .update! This will cause performance issues...")
184 | self._release_pending(1)
185 | else:
186 | self._release_pending(self._remaining)
187 | finally:
188 | self._reserved.remove(session)
189 | self._can_do_blind_request.set()
190 | logger.debug("Done cleaning up blind request!")
191 | return
192 |
193 | # Currently doing blind request
194 | await self._can_do_blind_request.wait()
195 |
196 | # Try again
197 | async with self.acquire(priority=priority, wait=wait):
198 | yield
199 |
200 | @overload
201 | async def update(self, *, unlimited: Literal[True]) -> None:
202 | ...
203 |
204 | @overload
205 | async def update(self, remaining: int, reset_after: float, *, unlimited: Literal[False] = False) -> None:
206 | ...
207 |
208 | async def update(
209 | self, remaining: int | None = None, reset_after: float | None = None, *, unlimited: bool = False
210 | ) -> None:
211 | if unlimited:
212 | # Updating metadata is handled by the HTTPClient, so we do not need to do this.
213 |
214 | # Release remaining requests
215 | self._release_pending()
216 | else:
217 | self._remaining = remaining
218 |
219 | # Start a reset
220 | if self._resetting:
221 | return # Don't do it when there is already a reset in progress
222 |
223 | self._resetting = True
224 |
225 | # Call the reset callback (after the reset duration)
226 | reset_after = cast(float, reset_after)
227 | loop = get_running_loop()
228 | loop.call_later(reset_after + self.reset_offset_seconds, self._reset_callback)
229 |
230 | def _reset_callback(self) -> None:
231 | self._resetting = False # Allow future resets
232 | self._remaining = None # It should use metadata's limit as a starting point.
233 |
234 | # Reset up to the limit
235 | self._release_pending(self.metadata.limit)
236 |
237 | def _release_pending(self, max_count: int | None = None):
238 | if max_count is None:
239 | max_count = self._pending.qsize()
240 | else:
241 | max_count = min(max_count, self._pending.qsize())
242 |
243 | for _ in range(max_count):
244 | session = self._pending.get_nowait() # This can't raise a exception due to the guard clause.
245 |
246 | # Mark it as completed in the queue to avoid a infinitly overflowing int
247 | self._pending.task_done()
248 |
249 | session.pending_future.set_result(None)
250 |
251 | @property
252 | def dirty(self) -> bool:
253 | """Whether the bucket is currently any different from a clean bucket created from a :class:`BucketMetadata`.
254 |
255 | This can be for example if any requests is being made, or if the bucket is waiting for a reset.
256 | """
257 | if self._reserved:
258 | return True # Currently doing a request.
259 |
260 | if self.metadata.unlimited:
261 | return False # Unlimited, following stuff does not matter
262 |
263 | if self._remaining is not None:
264 | return True # Some edits to remaining has been done
265 |
266 | return False
267 |
268 | async def close(self):
269 | """Cleanup this instance.
270 |
271 | This should be done when this instance is never going to be used anymore
272 |
273 | .. warning::
274 | Continued use of this instance will result in instability
275 | """
276 | for session in self._pending.queue:
277 | session.pending_future.set_exception(CancelledError)
278 |
--------------------------------------------------------------------------------
/nextcore/http/bucket_metadata.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from typing import TYPE_CHECKING
25 |
26 | if TYPE_CHECKING:
27 | from typing import Final
28 |
29 | __all__: Final[tuple[str, ...]] = ("BucketMetadata",)
30 |
31 |
32 | class BucketMetadata:
33 | """Metadata about a discord bucket.
34 |
35 | **Example usage**
36 |
37 | .. code-block:: python3
38 |
39 | bucket_metadata = BucketMetadata()
40 | bucket = Bucket(bucket_metadata)
41 |
42 | async with bucket.acquire():
43 | ...
44 |
45 | bucket_metadata.limit = 5 # This can be found in the response headers from discord.
46 | bucket_metadata.unlimited = False
47 |
48 | Parameters
49 | ----------
50 | limit:
51 | The maximum number of requests that can be made in the given time period.
52 | unlimited:
53 | Whether the bucket has an unlimited number of requests. If this is :class:`True`,
54 | limit has to be None.
55 |
56 | Attributes
57 | ----------
58 | limit:
59 | The maximum number of requests that can be made in the given time period.
60 |
61 | .. note::
62 | This will be :data:`None` if :attr:`BucketMetadata.unlimited` is :data:`True`.
63 |
64 | This will also be :data:`None` if no limit has been fetched yet.
65 | unlimited:
66 | Wheter the bucket has no rate limiting enabled.
67 | """
68 |
69 | __slots__ = ("limit", "unlimited")
70 |
71 | def __init__(self, limit: int | None = None, *, unlimited: bool = False) -> None:
72 | self.limit: int | None = limit
73 | self.unlimited: bool = unlimited
74 |
--------------------------------------------------------------------------------
/nextcore/http/client/__init__.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from .client import *
23 |
--------------------------------------------------------------------------------
/nextcore/http/client/base_client.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from abc import ABC, abstractmethod
25 | from logging import getLogger
26 | from typing import TYPE_CHECKING
27 |
28 | from ..route import Route
29 |
30 | if TYPE_CHECKING:
31 | from typing import Any, Final
32 |
33 | from aiohttp import ClientResponse
34 |
35 | logger = getLogger(__name__)
36 |
37 | __all__: Final[tuple[str, ...]] = ("BaseHTTPClient",)
38 |
39 |
40 | class BaseHTTPClient(ABC):
41 | """Base class for HTTP clients.
42 |
43 | This defines :meth:`BaseHTTPClient._request` for HTTP endpoint wrappers to use.
44 | """
45 |
46 | __slots__ = ()
47 |
48 | @abstractmethod
49 | async def request(
50 | self,
51 | route: Route,
52 | rate_limit_key: str | None,
53 | *,
54 | headers: dict[str, str] | None = None,
55 | bucket_priority: int = 0,
56 | global_priority: int = 0,
57 | wait: bool = True,
58 | **kwargs: Any,
59 | ) -> ClientResponse:
60 | ...
61 |
--------------------------------------------------------------------------------
/nextcore/http/errors.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from typing import TYPE_CHECKING
25 |
26 | if TYPE_CHECKING:
27 | from typing import Final
28 |
29 | from aiohttp import ClientResponse
30 | from discord_typings import HTTPErrorResponseData
31 |
32 | __all__: Final[tuple[str, ...]] = (
33 | "RateLimitingFailedError",
34 | "HTTPRequestStatusError",
35 | "BadRequestError",
36 | "UnauthorizedError",
37 | "ForbiddenError",
38 | "NotFoundError",
39 | "InternalServerError",
40 | "CloudflareBanError",
41 | )
42 |
43 |
44 | class RateLimitingFailedError(Exception):
45 | """When rate limiting has failed more than :attr:`HTTPClient.max_retries` times
46 |
47 | .. hint::
48 | This can be due to a un-syncronized clock.
49 |
50 | You can change :attr:`HTTPClient.trust_local_time` to :data:`False` to disable using your local clock,
51 | or you could sync your clock.
52 |
53 | .. tab:: Ubuntu
54 |
55 | You can check if your clock is synchronized by running the following command:
56 |
57 | .. code-block:: bash
58 |
59 | timedatectl
60 |
61 | If it is synchronized, it will show "System clock synchronized: yes" and "NTP service: running"
62 |
63 | If the system clock is not synchronized but the ntp service is running you will have to wait a few minutes for it to sync.
64 |
65 | To enable the ntp service run the following command:
66 |
67 | .. code-block:: bash
68 |
69 | sudo timedatectl set-ntp on
70 |
71 | This will automatically sync the system clock every once in a while.
72 |
73 | .. tab:: Arch
74 |
75 | You can check if your clock is synchronized by running the following command:
76 |
77 | .. code-block:: bash
78 |
79 | timedatectl
80 |
81 | If it is synchronized, it will show "System clock synchronized: yes" and "NTP service: running"
82 |
83 | If the system clock is not synchronized but the ntp service is running you will have to wait a few minutes for it to sync.
84 |
85 | To enable the ntp service run the following command:
86 |
87 | .. code-block:: bash
88 |
89 | sudo timedatectl set-ntp on
90 |
91 | This will automatically sync the system clock every once in a while.
92 |
93 | .. tab:: Windows
94 |
95 | This can be turned on by going to ``Settings -> Time & language -> Date & time`` and turning on ``Set time automatically``.
96 |
97 |
98 |
99 |
100 | Parameters
101 | ----------
102 | max_retries:
103 | How many retries the request used that failed.
104 | response:
105 | The response to the last request that failed.
106 |
107 | Attributes
108 | ----------
109 | max_retries:
110 | How many retries the request used that failed.
111 | response:
112 | The response to the last request that failed.
113 | """
114 |
115 | def __init__(self, max_retries: int, response: ClientResponse) -> None:
116 | self.max_retries: int = max_retries
117 | self.response: ClientResponse = response
118 |
119 | super().__init__(f"Ratelimiting failed more than {max_retries} times")
120 |
121 |
122 | # Expected errors
123 | class HTTPRequestStatusError(Exception):
124 | """A base error for receiving a status code the library doesn't expect.
125 |
126 | Parameters
127 | ----------
128 | error:
129 | The error json from the body.
130 | response:
131 | The response to the request.
132 |
133 | Attributes
134 | ----------
135 | response:
136 | The response to the request.
137 | error_code:
138 | The error code.
139 | message:
140 | The error message.
141 | error:
142 | The error json from the body.
143 | """
144 |
145 | def __init__(self, error: HTTPErrorResponseData, response: ClientResponse) -> None:
146 | self.response: ClientResponse = response
147 |
148 | self.error_code: int = error["code"]
149 | self.message: str = error["message"]
150 |
151 | self.error: HTTPErrorResponseData = error
152 |
153 | super().__init__(f"({self.error_code}) {self.message}")
154 |
155 |
156 | # TODO: Can the docstrings be improved here?
157 | class BadRequestError(HTTPRequestStatusError):
158 | """A 400 error."""
159 |
160 |
161 | class UnauthorizedError(HTTPRequestStatusError):
162 | """A 401 error."""
163 |
164 |
165 | class ForbiddenError(HTTPRequestStatusError):
166 | """A 403 error."""
167 |
168 |
169 | class NotFoundError(HTTPRequestStatusError):
170 | """A 404 error."""
171 |
172 |
173 | class InternalServerError(HTTPRequestStatusError):
174 | """A 5xx error."""
175 |
176 |
177 | class CloudflareBanError(Exception):
178 | """A error for when you get banned by cloudflare
179 |
180 | This happens due to getting too many ``401``, ``403`` or ``429`` responses from discord.
181 | This will block your access to the API temporarily for an hour.
182 |
183 | See the `documentation `__ for more info.
184 | """
185 |
--------------------------------------------------------------------------------
/nextcore/http/global_rate_limiter/__init__.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from typing import TYPE_CHECKING
25 |
26 | from .base import BaseGlobalRateLimiter
27 | from .limited import LimitedGlobalRateLimiter
28 | from .unlimited import UnlimitedGlobalRateLimiter
29 |
30 | if TYPE_CHECKING:
31 | from typing import Final
32 |
33 | __all__: Final[tuple[str, ...]] = ("BaseGlobalRateLimiter", "UnlimitedGlobalRateLimiter", "LimitedGlobalRateLimiter")
34 |
--------------------------------------------------------------------------------
/nextcore/http/global_rate_limiter/base.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from abc import ABC, abstractmethod
25 | from typing import TYPE_CHECKING
26 |
27 | if TYPE_CHECKING:
28 | from typing import AsyncContextManager, Final, TypeVar
29 |
30 | ExceptionT = TypeVar("ExceptionT", bound=BaseException)
31 |
32 | __all__: Final[tuple[str, ...]] = ("BaseGlobalRateLimiter",)
33 |
34 |
35 | class BaseGlobalRateLimiter(ABC):
36 | """A base implementation of a rate-limiter for global-scoped rate-limits.
37 |
38 | .. warning::
39 | This does not contain any implementation!
40 |
41 | You are probably looking for :class:`LimitedGlobalRateLimiter` or :class:`UnlimitedGlobalRateLimiter`
42 | """
43 |
44 | __slots__ = ()
45 |
46 | @abstractmethod
47 | def acquire(self, *, priority: int = 0, wait: bool = True) -> AsyncContextManager[None]:
48 | """Use a spot in the rate-limit.
49 |
50 | Parameters
51 | ----------
52 | priority:
53 | .. warning::
54 | This can safely be ignored.
55 |
56 | The request priority. **Lower** number means it will be requested earlier.
57 | wait:
58 | Whether to wait for a spot in the rate limit.
59 |
60 | If this is set to :data:`False`, this will raise a :exc:`RateLimitedError`
61 |
62 | Returns
63 | -------
64 | :class:`typing.AsyncContextManager`
65 | A context manager that will wait in __aenter__ until a request should be made.
66 | """
67 | ...
68 |
69 | @abstractmethod
70 | def update(self, retry_after: float) -> None:
71 | """Updates the rate-limiter with info from a global scoped 429.
72 |
73 | Parameters
74 | ----------
75 | retry_after:
76 | The time from the `retry_after` field in the JSON response or the `retry_after` header.
77 |
78 | .. hint::
79 | The JSON field has more precision than the header.
80 | """
81 | ...
82 |
83 | @abstractmethod
84 | async def close(self) -> None:
85 | """Cleanup this instance.
86 |
87 | This should be done when this instance is never going to be used anymore
88 |
89 | .. warning::
90 | Continued use of this instance may result in instability
91 | """
92 | ...
93 |
--------------------------------------------------------------------------------
/nextcore/http/global_rate_limiter/limited.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from logging import getLogger
25 | from typing import TYPE_CHECKING
26 |
27 | from ...common import TimesPer
28 | from .base import BaseGlobalRateLimiter
29 |
30 | if TYPE_CHECKING:
31 | from typing import Final
32 |
33 | __all__: Final[tuple[str, ...]] = ("LimitedGlobalRateLimiter",)
34 |
35 | logger = getLogger(__name__)
36 |
37 |
38 | class LimitedGlobalRateLimiter(TimesPer, BaseGlobalRateLimiter):
39 | """A limited global rate-limiter.
40 |
41 | Parameters
42 | ----------
43 | limit:
44 | The amount of requests that can be made per second.
45 | """
46 |
47 | __slots__ = ()
48 |
49 | def __init__(self, limit: int = 50) -> None:
50 | TimesPer.__init__(self, limit, 1)
51 |
52 | def update(self, retry_after: float) -> None:
53 | """A function that gets called whenever the global rate-limit gets exceeded
54 |
55 | This just makes a warning log.
56 | """
57 | logger.warning("Exceeded global rate-limit! (Retry after: %s)", retry_after)
58 |
--------------------------------------------------------------------------------
/nextcore/http/global_rate_limiter/unlimited.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from asyncio import CancelledError, Future, Lock, create_task, sleep
25 | from collections import deque
26 | from contextlib import asynccontextmanager
27 | from logging import getLogger
28 | from typing import TYPE_CHECKING, AsyncIterator
29 |
30 | from nextcore.common.errors import RateLimitedError
31 |
32 | from .base import BaseGlobalRateLimiter
33 |
34 | if TYPE_CHECKING:
35 | from asyncio import Task
36 | from typing import Final
37 |
38 | __all__: Final[tuple[str, ...]] = ("UnlimitedGlobalRateLimiter",)
39 |
40 | logger = getLogger(__name__)
41 |
42 |
43 | class UnlimitedGlobalRateLimiter(BaseGlobalRateLimiter):
44 | """A global rate-limiting implementation
45 |
46 | This works by allowing infinite requests until one fail,
47 | and when one fails stop further requests from being made until ``retry_after`` is done.
48 |
49 | .. warning::
50 | This may cause a lot of 429's. Please use :class:`LimitedGlobalRateLimiter`
51 | unless you are sure you need this.
52 | .. warning::
53 | This is slower than other implementations due to using Discord's time.
54 |
55 | There is some extra delay due to ping due to this.
56 | """
57 |
58 | __slots__ = ("_pending_requests", "_pending_release", "_async_update_task")
59 |
60 | def __init__(self) -> None:
61 | self._pending_requests: deque[Future[None]] = deque()
62 | self._pending_release: Lock = Lock()
63 | self._async_update_task: Task[None] | None = None
64 |
65 | @asynccontextmanager
66 | async def acquire(self, *, priority: int = 0, wait: bool = True) -> AsyncIterator[None]:
67 | """Acquire a spot in the rate-limit
68 |
69 | Parameters
70 | ----------
71 | priority:
72 | .. warning::
73 | Request priority currently does nothing.
74 | wait:
75 | Whether to wait for a spot in the rate limit.
76 |
77 | If this is :data:`False`, this will raise :exc:`RateLimitedError` instead.
78 |
79 | Raises
80 | ------
81 | RateLimitedError
82 | ``wait`` was set to :data:`False` and we are rate limited.
83 |
84 | Returns
85 | -------
86 | :class:`typing.AsyncContextManager`
87 | A context manager that will wait in __aenter__ until a request should be made.
88 | """
89 | del priority # Unused
90 | if self._pending_release.locked():
91 | # Rate limited!
92 |
93 | if not wait:
94 | raise RateLimitedError()
95 |
96 | # Add to queue
97 | future: Future[None] = Future()
98 | self._pending_requests.append(future)
99 |
100 | # Wait
101 | try:
102 | await future
103 | except CancelledError:
104 | logger.debug("Ratelimit use was cancelled while it was pending. Cancelling!")
105 | self._pending_requests.remove(future)
106 | raise # Don't continue
107 | yield None
108 |
109 | def update(self, retry_after: float) -> None:
110 | """Updates the rate-limiter with info from a global scoped 429.
111 |
112 | Parameters
113 | ----------
114 | retry_after:
115 | The time from the `retry_after` field in the JSON response or the `retry_after` header.
116 |
117 | .. hint::
118 | The JSON field has more precision than the header.
119 | """
120 | logger.debug("Exceeded global rate-limit, however this is expected.")
121 | if self._pending_release.locked():
122 | logger.debug("Ignoring update because of already running update task.")
123 | return
124 | self._async_update_task = create_task(self._async_update(retry_after))
125 |
126 | async def _async_update(self, retry_after: float) -> None:
127 | """Async version of :attr:`UnlimitedGlobalRateLimiter`"""
128 | # Sanity check due to race conditions
129 | if self._pending_release.locked():
130 | logger.debug("Ignoring update because of already running update task.")
131 | self._async_update_task = None
132 | return
133 | async with self._pending_release:
134 | logger.debug("Resetting global lock after %ss", retry_after)
135 | await sleep(retry_after)
136 | logger.debug("Resetting global lock!")
137 |
138 | # Let all requests run again.
139 | for future in self._pending_requests:
140 | # Remove it
141 | self._pending_requests.popleft()
142 |
143 | # Release it to do the request
144 | future.set_result(None)
145 |
146 | self._async_update_task = None
147 |
148 | async def close(self) -> None:
149 | """Cleanup this instance.
150 |
151 | This should be done when this instance is never going to be used anymore
152 |
153 | .. warning::
154 | Continued use of this instance will result in instability
155 | """
156 |
157 | # No need to run .clear on this, as the .acquire function does it for us.
158 | for request in self._pending_requests:
159 | request.set_exception(CancelledError)
160 |
161 | if self._async_update_task is not None:
162 | self._async_update_task.cancel()
163 |
--------------------------------------------------------------------------------
/nextcore/http/rate_limit_storage.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | import gc
25 | from logging import getLogger
26 | from typing import TYPE_CHECKING
27 | from weakref import WeakValueDictionary
28 |
29 | from .global_rate_limiter import BaseGlobalRateLimiter, LimitedGlobalRateLimiter
30 |
31 | if TYPE_CHECKING:
32 | from typing import Final, Literal
33 |
34 | from .bucket import Bucket
35 | from .bucket_metadata import BucketMetadata
36 |
37 | logger = getLogger(__name__)
38 |
39 | __all__: Final[tuple[str, ...]] = ("RateLimitStorage",)
40 |
41 |
42 | class RateLimitStorage:
43 | """Storage for rate limits for a user.
44 |
45 | One of these should be created for each user.
46 |
47 | .. note::
48 | This will register a gc callback to clean up the buckets.
49 |
50 | Attributes
51 | ----------
52 | global_lock:
53 | The users per user global rate limit.
54 | """
55 |
56 | __slots__ = ("_nextcore_buckets", "_discord_buckets", "_bucket_metadata", "global_rate_limiter")
57 |
58 | def __init__(self) -> None:
59 | self._nextcore_buckets: dict[str, Bucket] = {}
60 | self._discord_buckets: WeakValueDictionary[str, Bucket] = WeakValueDictionary()
61 | self._bucket_metadata: dict[
62 | str, BucketMetadata
63 | ] = {} # This will never get cleared however it improves performance so I think not deleting it is fine
64 | self.global_rate_limiter: BaseGlobalRateLimiter = LimitedGlobalRateLimiter()
65 |
66 | # Register a garbage collection callback
67 | gc.callbacks.append(self._cleanup_buckets)
68 |
69 | # These are async and not just public dicts because we want to support custom implementations that use asyncio.
70 | # This does introduce some overhead, but it's not too bad.
71 | async def get_bucket_by_nextcore_id(self, nextcore_id: str) -> Bucket | None:
72 | """Get a rate limit bucket from a nextcore created id.
73 |
74 | Parameters
75 | ----------
76 | nextcore_id:
77 | The nextcore generated bucket id. This can be gotten by using :attr:`Route.bucket`
78 | """
79 | return self._nextcore_buckets.get(nextcore_id)
80 |
81 | async def store_bucket_by_nextcore_id(self, nextcore_id: str, bucket: Bucket) -> None:
82 | """Store a rate limit bucket by nextcore generated id.
83 |
84 | Parameters
85 | ----------
86 | nextcore_id:
87 | The nextcore generated id of the
88 | bucket:
89 | The bucket to store.
90 | """
91 | self._nextcore_buckets[nextcore_id] = bucket
92 |
93 | async def get_bucket_by_discord_id(self, discord_id: str) -> Bucket | None:
94 | """Get a rate limit bucket from the Discord bucket hash.
95 |
96 | This can be obtained via the ``X-Ratelimit-Bucket`` header.
97 |
98 | Parameters
99 | ----------
100 | discord_id:
101 | The Discord bucket hash
102 | """
103 | return self._discord_buckets.get(discord_id)
104 |
105 | async def store_bucket_by_discord_id(self, discord_id: str, bucket: Bucket) -> None:
106 | """Store a rate limit bucket by the discord bucket hash.
107 |
108 | This can be obtained via the ``X-Ratelimit-Bucket`` header.
109 |
110 | Parameters
111 | ----------
112 | discord_id:
113 | The Discord bucket hash
114 | bucket:
115 | The bucket to store.
116 | """
117 | self._discord_buckets[discord_id] = bucket
118 |
119 | async def get_bucket_metadata(self, bucket_route: str) -> BucketMetadata | None:
120 | """Get the metadata for a bucket from the route.
121 |
122 | Parameters
123 | ----------
124 | bucket_route:
125 | The bucket route.
126 | """
127 | return self._bucket_metadata.get(bucket_route)
128 |
129 | async def store_metadata(self, bucket_route: str, metadata: BucketMetadata) -> None:
130 | """Store the metadata for a bucket from the route.
131 |
132 | Parameters
133 | ----------
134 | bucket_route:
135 | The bucket route.
136 | metadata:
137 | The metadata to store.
138 | """
139 | self._bucket_metadata[bucket_route] = metadata
140 |
141 | # Garbage collection
142 | def _cleanup_buckets(self, phase: Literal["start", "stop"], info: dict[str, int]) -> None:
143 | del info # Unused
144 |
145 | if phase == "stop":
146 | # No need to clean up buckets after the gc is done.
147 | return
148 | logger.debug("Cleaning up buckets")
149 |
150 | # We are copying it due to the size changing during the loop
151 | for bucket_id, bucket in self._nextcore_buckets.copy().items():
152 | if not bucket.dirty:
153 | logger.debug("Cleaning up bucket %s", bucket_id)
154 | # Delete the main reference. Other references like RateLimitStorage._discord_buckets should get cleaned up automatically as it is a weakref.
155 | del self._nextcore_buckets[bucket_id]
156 |
157 | async def close(self) -> None:
158 | """Clean up before deletion.
159 |
160 | .. warning::
161 | If this is not called before you delete this or it goes out of scope, you will get a memory leak.
162 | """
163 | # Remove the garbage collection callback
164 | gc.callbacks.remove(self._cleanup_buckets)
165 |
166 | await self.global_rate_limiter.close()
167 |
168 | # Clear up the buckets
169 | for bucket in self._nextcore_buckets.values():
170 | await bucket.close()
171 |
172 | self._nextcore_buckets.clear()
173 |
174 | # Clear up the metadata
175 | self._bucket_metadata.clear()
176 |
--------------------------------------------------------------------------------
/nextcore/http/request_session.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from asyncio import Future
25 | from typing import TYPE_CHECKING
26 |
27 | if TYPE_CHECKING:
28 | from typing import Final
29 |
30 | __all__: Final[tuple[str, ...]] = ("RequestSession",)
31 |
32 |
33 | class RequestSession:
34 | """A metadata class about a pending request. This is used by :class:`Bucket`
35 |
36 | Parameters
37 | ----------
38 | priority:
39 | The priority of the request. Lower is better!
40 | unlimited:
41 | If this request was made when the bucket was unlimited.
42 |
43 | This exists to make sure that there is no bad state when switching between unlimited and limited.
44 |
45 | Attributes
46 | ----------
47 | pending_future:
48 | The future that when set will execute the request.
49 | priority:
50 | The priority of the request. Lower is better!
51 | unlimited:
52 | If this request was made when the bucket was unlimited.
53 |
54 | This exists to make sure that there is no bad state when switching between unlimited and limited.
55 | """
56 |
57 | __slots__: Final[tuple[str, ...]] = ("pending_future", "priority", "unlimited")
58 |
59 | def __init__(self, *, priority: int = 0, unlimited: bool = False) -> None:
60 | self.pending_future: Future[None] = Future()
61 | self.priority: int = priority
62 | self.unlimited: bool = unlimited
63 |
64 | def __gt__(self, other: RequestSession):
65 | return self.priority > other.priority
66 |
--------------------------------------------------------------------------------
/nextcore/http/route.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright (c) 2021-present tag-epic
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a
5 | # copy of this software and associated documentation files (the "Software"),
6 | # to deal in the Software without restriction, including without limitation
7 | # the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 | # and/or sell copies of the Software, and to permit persons to whom the
9 | # Software is furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 | # DEALINGS IN THE SOFTWARE.
21 |
22 | from __future__ import annotations
23 |
24 | from typing import TYPE_CHECKING
25 |
26 | if TYPE_CHECKING:
27 | from typing import ClassVar, Final, Literal
28 |
29 | from discord_typings import Snowflake
30 | from typing_extensions import LiteralString
31 |
32 | __all__: Final[tuple[str, ...]] = ("Route",)
33 |
34 |
35 | class Route:
36 | """Metadata about a discord API route
37 |
38 | **Example usage**
39 |
40 | .. code-block:: python3
41 |
42 | route = Route("GET", "/guilds/{guild_id}", guild_id=1234567890)
43 |
44 | Parameters
45 | ----------
46 | method:
47 | The HTTP method of the route
48 | path:
49 | The path of the route. This can include python formatting strings ({var_here}) from kwargs
50 | ignore_global:
51 | If this route bypasses the global rate limit.
52 | guild_id:
53 | Major parameters which will be included in ``parameters`` and count towards the rate limit.
54 | channel_id:
55 | Major parameters which will be included in ``parameters`` and count towards the rate limit.
56 | webhook_id:
57 | Major parameters which will be included in ``parameters`` and count towards the rate limit.
58 | webhook_token:
59 | Major parameters which will be included in ``parameters`` and count towards the rate limit.
60 | parameters:
61 | The parameters of the route. These will be used to format the path.
62 |
63 | This will be included in :attr:`Route.bucket`
64 |
65 | Attributes
66 | ----------
67 | method:
68 | The HTTP method of the route
69 | route:
70 | The path of the route. This can include python formatting strings ({var_here}) from kwargs.
71 | path:
72 | The formatted version of :attr:`Route.route`
73 | ignore_global:
74 | If this route bypasses the global rate limit.
75 |
76 | This is always :data:`True` for unauthenticated routes.
77 | bucket:
78 | The rate limit bucket this fits in.
79 |
80 | This is created from :attr:`Route.guild_id`, :attr:`Route.channel_id`, :attr:`Route.webhook_id`, :attr:`Bucket.method` and :attr:`Route.path`
81 | """
82 |
83 | __slots__ = ("method", "route", "path", "ignore_global", "bucket")
84 |
85 | BASE_URL: ClassVar[str] = "https://discord.com/api/v10"
86 |
87 | def __init__(
88 | self,
89 | method: Literal[
90 | "GET",
91 | "HEAD",
92 | "POST",
93 | "PUT",
94 | "DELETE",
95 | "CONNECT",
96 | "OPTIONS",
97 | "TRACE",
98 | "PATCH",
99 | ],
100 | path: LiteralString,
101 | *,
102 | ignore_global: bool = False,
103 | guild_id: Snowflake | None = None,
104 | channel_id: Snowflake | None = None,
105 | webhook_id: Snowflake | None = None,
106 | webhook_token: str | None = None,
107 | **parameters: Snowflake,
108 | ) -> None:
109 | self.method: str = method
110 | self.route: str = path
111 | self.path: str = path.format(
112 | guild_id=guild_id, channel_id=channel_id, webhook_id=webhook_id, webhook_token=webhook_token, **parameters
113 | )
114 | self.ignore_global: bool = ignore_global
115 |
116 | self.bucket: str = f"{guild_id}{channel_id}{webhook_id}{webhook_token}{method}{path}"
117 |
--------------------------------------------------------------------------------
/nextcore/py.typed:
--------------------------------------------------------------------------------
1 | # Marker file for PEP 561.
2 | # This marks that we use inline typings for type checkers
3 |
--------------------------------------------------------------------------------
/pypi-README.md:
--------------------------------------------------------------------------------
1 |
9 |
10 | ### ✨ Features
11 |
12 | - #### Speed
13 |
14 | We try to make the library as fast as possible, without compromising on readability of the code or features.
15 |
16 | - #### Modularity
17 |
18 | All the components can easily be swapped out with your own.
19 |
20 | - #### Control
21 |
22 | Nextcore offers fine-grained control over things most libraries don't support.
23 |
24 | This currently includes:
25 | - Setting priority for individual requests
26 | - Swapping out components
27 |
28 |
29 |
30 |