├── .gitignore
├── .readthedocs.yaml
├── CHANGELOG.md
├── LICENSE
├── README.md
├── docs
├── Makefile
├── advanced.rst
├── api.rst
├── conf.py
├── examples.rst
├── getting_started.rst
├── index.rst
└── make.bat
├── pytero
├── __init__.py
├── app.py
├── client.py
├── errors.py
├── events.py
├── files.py
├── http.py
├── node.py
├── permissions.py
├── schedules.py
├── servers.py
├── shard.py
├── types.py
├── users.py
└── util.py
└── setup.py
/.gitignore:
--------------------------------------------------------------------------------
1 | # Hidden directories
2 | __pycache__/
3 | .vscode/
4 | .idea/
5 |
6 | # Documentation directories
7 | docs/_build
8 | docs/_static
9 | docs/_templates
10 |
11 | # Hidden files/types/executables
12 | *.cmd
13 | *.pyc
14 | *.pyi
15 | *.sh
16 | *.test.py
17 | test.py
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | build:
4 | os: ubuntu-20.04
5 | tools:
6 | python: '3.10'
7 |
8 | sphinx:
9 | configuration: docs/conf.py
10 | fail_on_warning: false
11 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | Tracking changes for Pytero (using [SemVer 2](http://semver.org/)).
3 |
4 | [0.1.0] - 07-2022
5 | Initial commit, first release.
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021-present PteroPackages
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
Pytero
2 | A flexible API wrapper for Pterodactyl in Python
3 |

4 |
5 | ## About
6 | Pytero is a flexible API wrapper for the [Pterodactyl Game Panel](https://pterodactyl.io) written Python using `async`/`await` syntax and up-to-date typings for proper type-checker support. If you are looking for an API wrapper with sync support, consider using [Pydactyl](https://github.com/iamkubi/pydactyl).
7 |
8 | ## Installing
9 | ```
10 | pip install git+https://github.com/PteroPackages/Pytero.git
11 | ```
12 |
13 | ## Getting Started
14 |
15 | ### Using the Application API
16 | ```python
17 | import asyncio
18 | from pytero import PteroApp
19 |
20 |
21 | # initialize the application
22 | app = PteroApp('your.domain.name', 'pterodactyl_api_key')
23 |
24 | async def main():
25 | # get all servers
26 | servers = await app.get_servers()
27 | for server in servers:
28 | print(server)
29 |
30 |
31 | # run the function
32 | asyncio.run(main())
33 | ```
34 |
35 | ### Using the Client API
36 | ```python
37 | from pytero import PteroClient
38 |
39 |
40 | # initialize the client
41 | client = PteroClient('your.domain.name', 'pterodactyl_api_key')
42 | # create the websocket shard
43 | shard = client.create_shard('280e5b1d')
44 |
45 | # listen for status updates
46 | @shard.event
47 | def on_status_update(status):
48 | print('server %s status: %s' % (shard.identifier, status))
49 |
50 |
51 | # launch the shard
52 | shard.launch()
53 | ```
54 |
55 |
59 |
60 | ## Contributors
61 | - [Devonte W](https://github.com/devnote-dev) - Owner, maintainer
62 |
63 | This repository is managed under the MIT license.
64 |
65 | © 2021-present PteroPackages
66 |
--------------------------------------------------------------------------------
/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 ?=
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/advanced.rst:
--------------------------------------------------------------------------------
1 | Advanced
2 | ========
3 |
4 | .. toctree::
5 | :maxdepth: 1
6 |
--------------------------------------------------------------------------------
/docs/api.rst:
--------------------------------------------------------------------------------
1 | .. currentmodule:: pytero
2 |
3 | API Reference
4 | =============
5 | .. toctree::
6 | :maxdepth: 6
7 |
8 | Application
9 | -----------
10 | .. autoclass:: PteroApp
11 | :members:
12 | :exclude-members: event
13 |
14 | .. autodecorator:: pytero.app.PteroApp.event()
15 |
16 | Client
17 | ------
18 |
19 | .. autoclass:: PteroClient
20 | :members:
21 | :exclude-members: event
22 |
23 | .. autodecorator:: pytero.client.PteroClient.event()
24 |
25 | .. automodule:: pytero.files
26 | :members:
27 |
28 | Events
29 | ------
30 |
31 | .. autoclass:: pytero.Emitter
32 | :members:
33 |
34 | HTTP
35 | ----
36 |
37 | .. autoclass:: pytero.RequestManager
38 | :members:
39 |
40 | Errors
41 | ------
42 |
43 | .. automodule:: pytero.errors
44 | :members:
45 |
46 | Types
47 | -----
48 |
49 | .. autoclass:: pytero.Node
50 | :members:
51 |
52 | .. automodule:: pytero.servers
53 | :members:
54 |
55 | .. automodule:: pytero.users
56 | :members:
57 |
58 | .. automodule:: pytero.schedules
59 | :members:
60 |
61 | .. automodule:: pytero.types
62 | :members:
63 |
64 | Permissions
65 | -----------
66 |
67 | .. automodule:: pytero.permissions
68 | :members:
69 |
70 | Utilities
71 | ---------
72 |
73 | .. automodule:: pytero.util
74 | :members:
75 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # For the full list of built-in configuration values, see the documentation:
4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
5 |
6 | # -- Project information -----------------------------------------------------
7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
8 |
9 | import os
10 | import sys
11 |
12 |
13 | sys.path.insert(0, os.path.abspath('..'))
14 |
15 | project = 'Pytero'
16 | copyright = '2022-present, Devonte W'
17 | author = 'Devonte W'
18 | release = '1.0.0'
19 |
20 | extensions = [
21 | 'sphinx.ext.autodoc',
22 | 'sphinx.ext.extlinks',
23 | 'sphinx.ext.intersphinx',
24 | 'sphinx_rtd_theme'
25 | ]
26 |
27 | intersphinx_mapping = {'py': ('https://docs.python.org/3', None)}
28 |
29 | templates_path = ['_templates']
30 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
31 | html_theme = 'sphinx_rtd_theme'
32 |
33 | resource_links = {
34 | 'discord': 'https://discord.com/invite/dwcfTjgn7S',
35 | 'issues': 'https://github.com/PteroPackages/Pytero/issues'
36 | }
37 |
--------------------------------------------------------------------------------
/docs/examples.rst:
--------------------------------------------------------------------------------
1 | Examples
2 | ========
3 |
4 | .. toctree::
5 | :maxdepth: 1
6 |
--------------------------------------------------------------------------------
/docs/getting_started.rst:
--------------------------------------------------------------------------------
1 | Getting Started
2 | ===============
3 |
4 | .. toctree::
5 | :maxdepth: 4
6 |
7 | Installing
8 | ----------
9 | Using pip:
10 |
11 | .. code::
12 |
13 | pip install git+https://github.com/PteroPackages/Pytero.git
14 |
15 | From Sources:
16 |
17 | .. code::
18 |
19 | git clone https://github.com/PteroPackages/Pytero.git
20 |
21 | Structure
22 | ---------
23 | All of Pytero's API methods are asynchronous and make use of futures which makes use of the native
24 | ``asyncio`` library to run async programs. If you do not want to use asynchronous code for your
25 | program, we recommend using the `Pydactyl `_ API wrapper.
26 |
27 | Application API
28 | ---------------
29 | First, import the :class:`PteroApp` class in your file, this holds all the functions for
30 | interacting with the application API. Make sure to also import the ``asyncio`` library (we will
31 | need this later).
32 |
33 | .. code:: python
34 |
35 | import asyncio
36 | from pytero import PteroApp
37 |
38 | The class takes 2 values for initializing: your Pterodactyl panel URL, and your API key. This
39 | supports the use of both application keys and client keys, but we recommend that you use a
40 | client key as application keys are deprecated.
41 |
42 | .. code:: python
43 |
44 | app = PteroApp('https://your.pterodactyl.domain', 'ptlc_Your_4pi_k3y')
45 |
46 | Next, define a ``main`` function to run your program, in this example we will be fetching all users
47 | from the API.
48 |
49 | .. code:: python
50 |
51 | async def main():
52 | users = await app.get_users()
53 | for user in users:
54 | print(repr(user))
55 |
56 | The ``PteroApp`` method naming convention is designed to be straightforward: all ``fetch_`` or
57 | ``get_`` methods will fetch a specific resource, all ``set_`` or ``update_`` will update a specific
58 | resource, all ``delete_`` or ``remove_`` methods will delete a specific resource, and so on. These
59 | methods are also documented in the :doc:`api`.
60 |
61 | Finally, run your program with the ``asyncio.run()`` method. It's good practice to wrap the call in
62 | a name check like below, but you can omit it for this program if you want.
63 |
64 | .. code:: python
65 |
66 | if __name__ == '__main__':
67 | asyncio.run(main())
68 |
69 | If you've done this correctly, you should see a list of ``User`` objects in ``repr`` form printed
70 | to the console:
71 |
72 | .. code::
73 |
74 |
75 |
76 |
77 |
78 | The full program:
79 |
80 | .. code:: python
81 |
82 | import asyncio
83 | from pytero import PteroApp
84 |
85 | app = PteroApp('https://your.pterodactyl.domain', 'ptlc_Your_4pi_k3y')
86 |
87 | async def main():
88 | users = await app.get_users()
89 | for user in users:
90 | print(repr(user))
91 |
92 |
93 | if __name__ == '__main__':
94 | asyncio.run(main())
95 |
96 | .. Client API
97 | ----------
98 | TODO...
99 |
100 | Next Steps
101 | ----------
102 | Well done for completing this guide! Feel free to take a look at the :doc:`advanced` section for
103 | more advanced usage of the package and API, or the :doc:`api` for more information about the
104 | library.
105 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | .. Pytero documentation master file, created by
2 | sphinx-quickstart on Sat Oct 8 09:59:32 2022.
3 | You can adapt this file completely to your liking, but it should at least
4 | contain the root `toctree` directive.
5 |
6 | Welcome to Pytero's documentation!
7 | ==================================
8 | Pytero is a flexible API wrapper for the `Pterodactyl Game Panel `_ in
9 | Python using ``async``/``await`` syntax and up-to-date typings for proper type-checker support.
10 |
11 | Getting Started
12 | ---------------
13 | * :doc:`getting_started`
14 | * :doc:`examples`
15 | * :doc:`advanced`
16 |
17 | Support
18 | -------
19 | Feel free to join our `Discord server `_ or open an issue on
20 | the `Github repo `_ for support.
21 |
22 | .. toctree::
23 | :maxdepth: 1
24 |
25 | getting_started
26 | examples
27 | advanced
28 | api
29 |
30 | Indices and tables
31 | ==================
32 | * :ref:`genindex`
33 | * :ref:`modindex`
34 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | PUSHD %~dp0
4 |
5 | :: Command file for Sphinx documentation
6 |
7 | IF "%SPHINXBUILD%" == "" (
8 | SET SPHINXBUILD=sphinx-build
9 | )
10 | SET SOURCEDIR=.
11 | SET BUILDDIR=_build
12 |
13 | %SPHINXBUILD% >NUL 2>NUL
14 | IF ERRORLEVEL 9009 (
15 | ECHO.
16 | ECHO.The 'sphinx-build' command was not found. Make sure you have Sphinx
17 | ECHO.installed, then set the SPHINXBUILD environment variable to point
18 | ECHO.to the full path of the 'sphinx-build' executable. Alternatively you
19 | ECHO.may add the Sphinx directory to PATH.
20 | ECHO.
21 | ECHO.If you don't have Sphinx installed, grab it from
22 | ECHO.https://www.sphinx-doc.org/
23 | EXIT /b 1
24 | )
25 |
26 | IF "%1" == "" GOTO help
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
--------------------------------------------------------------------------------
/pytero/__init__.py:
--------------------------------------------------------------------------------
1 | """# Pytero
2 | A flexible API wrapper for the Pterodactyl API
3 |
4 | Author: Devonte W
5 | Repository: https://github.com/PteroPackages/Pytero
6 | License: MIT
7 |
8 | © 2021-present PteroPackages
9 | """
10 |
11 | # flake8: noqa
12 |
13 | from .app import PteroApp
14 | from .client import PteroClient
15 | from .errors import *
16 | from .events import Emitter
17 | from .files import *
18 | from .http import RequestManager
19 | from .node import Node
20 | from .permissions import *
21 | from .schedules import Schedule
22 | from .servers import *
23 | from .shard import Shard
24 | from .types import *
25 | from .users import *
26 |
27 |
28 | __title__ = 'Pytero'
29 | __version__ = '0.1.0'
30 | __license__ = 'MIT'
31 |
--------------------------------------------------------------------------------
/pytero/app.py:
--------------------------------------------------------------------------------
1 | """The main class/interface for interacting with the Pterodactyl application
2 | API.
3 |
4 | This supports application and client API keys at:
5 | https://your.pterodactyl.domain/admin/api or
6 | https://your.pterodactyl.domain/account/api.
7 | """
8 |
9 | # pylint: disable=R0904
10 |
11 | from .http import RequestManager
12 | from .node import Node
13 | from .servers import AppServer
14 | from .types import Allocation, AppDatabase, DeployNodeOptions, \
15 | DeployServerOptions, Egg, FeatureLimits, Limits, Location, Nest, \
16 | NodeConfiguration
17 | from .users import User
18 |
19 |
20 | __all__ = ('PteroApp',)
21 |
22 |
23 | class PteroApp:
24 | """A class/interface for interacting with the application API.
25 |
26 | url: :class:`str`
27 | The URL of the Pterodactyl domain. This must be an absolute URL, not
28 | one that contains paths (trailing forward slash is allowed).
29 | key: :class:`str`
30 | The API key to use for HTTP requests. This can be either an
31 | application API key or a Client API key (as of Pterodactyl v1.8).
32 | """
33 |
34 | def __init__(self, url: str, key: str) -> None:
35 | self.url = url.removesuffix('/')
36 | self.key = key
37 | self._http = RequestManager('application', self.url, key)
38 |
39 | def __repr__(self) -> str:
40 | return ''
41 |
42 | @property
43 | def event(self):
44 | """Returns the HTTP class event decorator for registering events to
45 | trigger on.
46 | """
47 | return self._http.event
48 |
49 | async def get_users(
50 | self,
51 | *,
52 | _filter: tuple[str, str] = None,
53 | include: list[str] = None,
54 | sort: str = None
55 | ) -> list[User]:
56 | """Returns a list of users from the API with the given options if
57 | specified.
58 |
59 | filter: Optional[tuple[:class:`str`, :class:`str`]]
60 | A tuple containing the filter type and argument to filter by
61 | (default is ``None``). This supports:
62 | * email
63 | * uuid
64 | * username
65 | * external_id
66 | include: Optional[list[:class:`str`]]
67 | A list of additional resources to include (default is ``None``).
68 | This supports:
69 | * servers
70 | sort: Optional[:class:`str`]
71 | The order to sort the results in (default is ``None``). This
72 | supports:
73 | * id
74 | * uuid
75 | """
76 | data = await self._http.get('/users', _filter=_filter, include=include,
77 | sort=sort)
78 | return [User(self, datum['attributes']) for datum in data['data']]
79 |
80 | async def get_user(
81 | self,
82 | _id: int,
83 | *,
84 | include: list[str] = None
85 | ) -> User:
86 | """Returns a user from the API with the given ID.
87 |
88 | id: :class:`int`
89 | The ID of the user.
90 | include: Optional[list[:class:`str`]]
91 | A list of additional resources to include (default is ``None``).
92 | This supports:
93 | * servers
94 | """
95 | data = await self._http.get(f'/users/{_id}', include=include)
96 | return User(self, data['attributes'])
97 |
98 | async def get_external_user(self, _id: str, /) -> User:
99 | """Returns a user from the API with the given external identifier.
100 |
101 | id: :class:`str`
102 | The external identifier of the user.
103 | """
104 | data = await self._http.get(f'/users/external/{_id}')
105 | return User(self, data['attributes'])
106 |
107 | async def create_user(
108 | self,
109 | *,
110 | email: str,
111 | username: str,
112 | first_name: str,
113 | last_name: str,
114 | password: str = None,
115 | external_id: str = None,
116 | root_admin: bool = False
117 | ) -> User:
118 | """Creates a new user account with the given fields.
119 |
120 | email: :class:`str`
121 | The email for the user.
122 | username: :class:`str`
123 | The username for the user.
124 | fist_name: :class:`str`
125 | The first name of the user.
126 | last_name: :class:`str`
127 | The last name of the user.
128 | password: Optional[:class:`str`]
129 | The password for the user (default is ``None``).
130 | external_id: Optional[:class:`str`]
131 | An external identifier for the user (default is ``None``).
132 | root_admin: Optional[:class:`bool`]
133 | Whether the user should be considered an admin (default is
134 | ``False``).
135 | """
136 | data = await self._http.post(
137 | '/users',
138 | {
139 | 'email': email,
140 | 'username': username,
141 | 'first_name': first_name,
142 | 'last_name': last_name,
143 | 'password': password,
144 | 'external_id': external_id,
145 | 'root_admin': root_admin
146 | })
147 |
148 | return User(self, data['attributes'])
149 |
150 | async def update_user(
151 | self,
152 | _id: int,
153 | *,
154 | email: str = None,
155 | username: str = None,
156 | first_name: str = None,
157 | last_name: str = None,
158 | password: str = None,
159 | external_id: str = None,
160 | root_admin: bool = False
161 | ) -> User:
162 | """Updates a specified user with the given fields.
163 |
164 | id: :class:`int`
165 | The ID of the user to update.
166 | email: Optional[:class:`str`]
167 | The email for the user (default is the current value).
168 | username: Optional[:class:`str`]
169 | The username for the user (default is the current value).
170 | fist_name: Optional[:class:`str`]
171 | The first name of the user (default is the current value).
172 | last_name: Optional[:class:`str`]
173 | The last name of the user (default is the current value).
174 | password: Optional[:class:`str`]
175 | The password for the user (default is ``None``).
176 | external_id: Optional[:class:`str`]
177 | An external identifier for the user (default is the current value).
178 | root_admin: Optional[:class:`bool`]
179 | Whether the user should be considered an admin (default is the
180 | current value).
181 | """
182 | old = await self.get_user(_id)
183 | body = {
184 | 'email': email or old.email,
185 | 'username': username or old.username,
186 | 'first_name': first_name or old.first_name,
187 | 'last_name': last_name or old.last_name,
188 | 'external_id': external_id or old.external_id,
189 | 'root_admin': root_admin if root_admin is not None else old.root_admin} # noqa: E501
190 |
191 | if password is not None:
192 | body['password'] = password
193 |
194 | data = await self._http.patch(f'/users/{_id}', body)
195 | return User(self, data['attributes'])
196 |
197 | def delete_user(self, _id: int, /) -> None:
198 | """Deletes a user by its ID.
199 |
200 | id: :class:`int`
201 | The ID of the user to delete.
202 | """
203 | return self._http.delete(f'/users/{_id}')
204 |
205 | async def get_servers(
206 | self,
207 | *,
208 | _filter: tuple[str, str] = None,
209 | include: list[str] = None,
210 | sort: str = None
211 | ) -> list[AppServer]:
212 | """Returns a list of servers from the API with the given options if
213 | specified.
214 |
215 | filter: Optional[tuple[:class:`str`, :class:`str`]]
216 | A tuple containing the filter type and argument to filter by
217 | (default is ``None``). This supports:
218 | * uuid
219 | * uuidShort
220 | * name
221 | * description
222 | * image
223 | * external_id
224 | include: Optional[list[:class:`str`]]
225 | A list of additional resources to include (default is ``None``).
226 | This supports:
227 | * allocations
228 | * nest
229 | * egg
230 | * location
231 | sort: Optional[:class:`str`]
232 | The order to sort the results in (default is ``None``). This
233 | supports:
234 | * id
235 | * uuid
236 | """
237 | data = await self._http.get('/servers',
238 | _filter=_filter, include=include,
239 | sort=sort)
240 | return [AppServer(self, datum['attributes']) for datum in data['data']]
241 |
242 | async def get_server(
243 | self,
244 | _id: int,
245 | *,
246 | include: list[str] = None
247 | ) -> AppServer:
248 | """Returns a server from the API with the given ID.
249 |
250 | include: Optional[list[:class:`str`]]
251 | A list of additional resources to include (default is ``None``).
252 | This supports:
253 | * allocations
254 | * nest
255 | * egg
256 | * location
257 | """
258 | data = await self._http.get(f'/servers/{_id}', include=include)
259 | return AppServer(self, data['attributes'])
260 |
261 | async def get_external_server(self, _id: str, /) -> AppServer:
262 | """Returns a server from the API with the given external identifier.
263 |
264 | id: :class:`str`
265 | The external identifier of the server.
266 | """
267 | data = await self._http.get(f'/servers/external/{_id}')
268 | return AppServer(self, data['attributes'])
269 |
270 | async def create_server(
271 | self,
272 | *,
273 | name: str,
274 | user: int,
275 | egg: int,
276 | docker_image: str,
277 | startup: str,
278 | environment: dict[str, int | str | bool],
279 | limits: Limits,
280 | feature_limits: FeatureLimits,
281 | external_id: str = None,
282 | default_allocation: int = None,
283 | additional_allocations: list[int] = None,
284 | deploy: DeployServerOptions = None,
285 | skip_scripts: bool = False,
286 | oom_disabled: bool = True,
287 | start_on_completion: bool = False
288 | ) -> AppServer:
289 | """Creates a new server with the given fields.
290 |
291 | name: :class:`str`
292 | The name of the server.
293 | user: :class:`int`
294 | The ID of the user that will own the server.
295 | egg: :class:`int`
296 | The ID of the egg to use for the server.
297 | docker_image: :class:`str`
298 | The docker image to use for the server.
299 | startup: :class:`str`
300 | The startup command for the server.
301 | environment: :class:`dict`
302 | The environment variables to set for the server.
303 | limits: :class:`Limits`
304 | The server resource limits.
305 | feature_limits: :class:`FeatureLimits`
306 | The server feature limits.
307 | external_id: Optional[:class:`str`]
308 | The external identifier for the server (default is ``None``).
309 | default_allocation: Optional[:class:`int`]
310 | The ID of the default allocation for the server. This must be
311 | specified unless the `deploy` object options is set (default is
312 | ``None``).
313 | additional_allocations: Optional[list[:class:`int`]]
314 | A list of additional allocation IDs to be added to the server
315 | (default is ``None``).
316 | deploy: Optional[:class:`DeployServerOptions`]
317 | An object containing the deploy options for the server. This must
318 | be specified unless the `default_allocation` option is set (default
319 | is ``None``).
320 | skip_scripts: Optional[:class:`bool`]
321 | Whether the server should skip the egg install script during
322 | installation (default is ``false``).
323 | oom_disabled: Optional[:class:`bool`]
324 | Whether the OOM killer should be disabled (default is ``True``).
325 | start_on_completion: Optional[:class:`bool`]
326 | Whether the server should start once the installation is complete
327 | (default is ``False``).
328 | """
329 | body = {
330 | 'name': name,
331 | 'user': user,
332 | 'egg': egg,
333 | 'docker_image': docker_image,
334 | 'startup': startup,
335 | 'environment': environment,
336 | 'limits': limits.to_dict(),
337 | 'feature_limits': feature_limits.to_dict(),
338 | 'external_id': external_id,
339 | 'skip_scripts': skip_scripts,
340 | 'oom_disabled': oom_disabled,
341 | 'start_on_completion': start_on_completion}
342 |
343 | if deploy is not None:
344 | body['deploy'] = deploy.to_dict()
345 | else:
346 | body['allocation'] = {
347 | 'default': default_allocation,
348 | 'additional': additional_allocations}
349 |
350 | data = await self._http.post('/servers', body)
351 | return AppServer(self, data['attributes'])
352 |
353 | async def update_server_details(
354 | self,
355 | _id: int,
356 | *,
357 | external_id: str = None,
358 | name: str = None,
359 | user: int = None,
360 | description: str = None
361 | ) -> AppServer:
362 | """Updates the details for a specified server.
363 |
364 | id: :class:`int`
365 | The ID of the server.
366 | external_id: Optional[:class:`str`]
367 | The external identifier of the server (defaults to the original if
368 | unset).
369 | name: Optional[:class:`str`]
370 | The name of the server (defaults to the original if unset).
371 | user: Optional[:class:`int`]
372 | The ID of the server owner (defaults to the original if unset).
373 | description: Optional[:class:`str`]
374 | The description of the server (defaults to the original if unset).
375 | """
376 | old = await self.get_server(_id)
377 | data = await self._http.patch(
378 | f'/servers/{_id}/details',
379 | {
380 | 'external_id': external_id or old.external_id,
381 | 'name': name or old.name,
382 | 'user': user or old.user,
383 | 'description': description or old.description
384 | })
385 |
386 | return AppServer(self, data['attributes'])
387 |
388 | async def update_server_build(
389 | self,
390 | _id: int,
391 | *,
392 | allocation: int = None,
393 | oom_disabled: bool = True,
394 | limits: Limits = None,
395 | feature_limits: FeatureLimits = None,
396 | add_allocations: list[int] = None,
397 | remove_allocations: list[int] = None
398 | ) -> AppServer:
399 | """Updates the build details of a specified server.
400 |
401 | id: :class:`int`
402 | The ID of the server.
403 | allocation: Optional[:class:`int`]
404 | The ID of the primary allocation for the server (defaults to the
405 | original if unset).
406 | oom_disabled: Optional[:class:`bool`]
407 | Whether OOM should be disabled for the server (defaults to the
408 | original if unset).
409 | limits: Optional[:class:`Limits`]
410 | The resource limits for the server (defaults to the original if
411 | unset).
412 | feature_limits: Optional[:class:`FeatureLimits`]
413 | The feature limits for the server (defaults to the original if
414 | unset).
415 | add_allocations: Optional[list[:class:`int`]]
416 | A list of allocation IDs to add to the server (defaults to
417 | ``None``).
418 | remove_allocations: Optional[list[:class:`int`]]
419 | A list of allocation IDs to remove from the server (defaults to
420 | ``None``).
421 | """
422 | old = await self.get_server(_id)
423 | data = await self._http.patch(
424 | f'/servers/{_id}/build',
425 | {
426 | 'allocation': allocation or old.allocation_id,
427 | 'oom_disabled': oom_disabled,
428 | 'limits': (limits or old.limits).to_dict(),
429 | 'feature_limits':
430 | (feature_limits or old.feature_limits).to_dict(),
431 | 'add_allocations': add_allocations or [],
432 | 'remove_allocations': remove_allocations or []
433 | })
434 |
435 | return AppServer(self, data['attributes'])
436 |
437 | async def update_server_startup(
438 | self,
439 | _id: int,
440 | *,
441 | startup: str = None,
442 | environment: dict[str, int | str | bool] = None,
443 | egg: int = None,
444 | image: str = None,
445 | skip_scripts: bool = False
446 | ) -> AppServer:
447 | """Updates the startup configuration for a specified server.
448 |
449 | id: :class:`int`
450 | The ID of the server.
451 | startup: Optional[:class:`str`]
452 | The startup command for the server (defaults to the original if
453 | unset).
454 | environment: Optional[:class:`dict`]
455 | The environment variables to set for the server (defaults to
456 | ``None``). This will update all variables at the same time and
457 | remove any variables that aren't specified from the server.
458 | egg: Optional[:class:`int`]
459 | The ID of the egg to use (defaults to the original if unset).
460 | image: Optional[:class:`str`]
461 | The docker image to use for the serverd (defaults to the original
462 | if unset).
463 | skip_scripts: Optional[:class:`bool`]
464 | Whether the server should skip the egg install script during
465 | installation (defaults to the original if unset).
466 | """
467 | old = await self.get_server(_id)
468 | data = await self._http.patch(
469 | f'/servers/{_id}/startup',
470 | {
471 | 'startup': startup or old.container.startup_command,
472 | 'environment': environment or old.container.environment,
473 | 'egg': egg or old.egg_id,
474 | 'image': image or old.container.image,
475 | 'skip_scripts': skip_scripts
476 | })
477 |
478 | return AppServer(self, data['attributes'])
479 |
480 | def suspend_server(self, _id: int, /) -> None:
481 | """Suspends a server by its ID.
482 |
483 | id: :class:`int`
484 | The ID of the server.
485 | """
486 | return self._http.post(f'/servers/{_id}/suspend', None)
487 |
488 | def unsuspend_server(self, _id: int, /) -> None:
489 | """Unsuspends a server by its ID.
490 |
491 | id: :class:`int`
492 | The ID of the server.
493 | """
494 | return self._http.post(f'/servers/{_id}/unsuspend', None)
495 |
496 | def reinstall_server(self, _id: int, /) -> None:
497 | """Triggers the reinstall process of a server by its ID.
498 |
499 | id: :class:`int`
500 | The ID of the server.
501 | """
502 | return self._http.post(f'/servers/{_id}/reinstall', None)
503 |
504 | def delete_server(self, _id: int, *, force: bool = False) -> None:
505 | """Deletes a server by its ID.
506 |
507 | id: :class:`int`
508 | The ID of the server.
509 | force: Optional[:class:`bool`]
510 | Whether the server should be deleted with force.
511 | """
512 | return self._http.delete(
513 | f'/servers/{_id}' + ('/force' if force else ''))
514 |
515 | async def get_server_databases(
516 | self,
517 | server: int,
518 | *,
519 | include: list[str] = None
520 | ) -> list[AppDatabase]:
521 | data = await self._http.get(f'/servers/{server}/databases',
522 | include=include)
523 |
524 | return [AppDatabase(**datum['attributes']) for datum in data['data']]
525 |
526 | async def get_server_database(
527 | self,
528 | server: int,
529 | _id: int,
530 | *,
531 | include: list[str] = None
532 | ) -> AppDatabase:
533 | data = await self._http.get(f'/servers/{server}/databases/{_id}',
534 | include=include)
535 |
536 | return AppDatabase(**data['attributes'])
537 |
538 | async def create_database(
539 | self,
540 | server: int,
541 | *,
542 | database: str,
543 | remote: str
544 | ) -> AppDatabase:
545 | data = await self._http.post(f'/servers/{server}/databases',
546 | {'database': database, 'remote': remote})
547 |
548 | return AppDatabase(**data['attributes'])
549 |
550 | async def reset_database_password(self, server: int,
551 | _id: int) -> AppDatabase:
552 | """Resets the password for a specified database.
553 |
554 | server: :class:`int`
555 | The ID of the server.
556 | id: :class:`int`
557 | The ID of the server database.
558 | """
559 | data = await self._http.post(
560 | f'/servers/{server}/databases/{_id}/reset-password',
561 | None)
562 |
563 | return AppDatabase(**data['attributes'])
564 |
565 | def delete_database(self, server: int, _id: int) -> None:
566 | """Deletes a server database by its ID.
567 |
568 | id: :class:`int`
569 | The ID of the server database.
570 | """
571 | return self._http.delete(f'/servers/{server}/databases/{_id}')
572 |
573 | async def get_nodes(
574 | self,
575 | *,
576 | _filter: tuple[str, str] = None,
577 | include: list[str] = None,
578 | sort: str = None
579 | ) -> list[Node]:
580 | data = await self._http.get('/nodes', _filter=_filter,
581 | include=include, sort=sort)
582 | return [Node(self, datum['attributes']) for datum in data['data']]
583 |
584 | async def get_node(
585 | self,
586 | _id: int,
587 | *,
588 | include: list[str] = None
589 | ) -> Node:
590 | data = await self._http.get(f'/nodes/{_id}', include=include)
591 | return Node(self, data['attributes'])
592 |
593 | async def get_deployable_nodes(self,
594 | options: DeployNodeOptions, /) -> list[Node]: # noqa: E501
595 | data = await self._http.get('/nodes/deployable',
596 | body=options.to_dict())
597 | return [Node(self, datum['attributes']) for datum in data['data']]
598 |
599 | async def get_node_configuration(self, _id: int, /) -> NodeConfiguration:
600 | """Returns the configuration of a specified node.
601 |
602 | id: :class:`int`
603 | The ID of the node.
604 | """
605 | data = await self._http.get(f'/nodes/{_id}/configuration')
606 | return NodeConfiguration(**data)
607 |
608 | def create_node(self) -> None:
609 | """TODO: Creates a new node with the given fields."""
610 | return NotImplemented
611 |
612 | def update_node(self) -> None:
613 | """TODO: Updates a specified node with the given fields."""
614 | return NotImplemented
615 |
616 | def delete_node(self, _id: int, /) -> None:
617 | """Deletes a node by its ID.
618 |
619 | id: :class:`int`
620 | The ID of the node.
621 | """
622 | return self._http.delete(f'/nodes/{_id}')
623 |
624 | async def get_node_allocations(self, node: int, /) -> list[Allocation]:
625 | data = await self._http.get(f'/nodes/{node}/allocations')
626 | return [Allocation(**datum['attributes']) for datum in data['data']]
627 |
628 | async def create_node_allocation(
629 | self,
630 | node: int,
631 | *,
632 | ip: str,
633 | ports: list[str],
634 | alias: str = None
635 | ) -> None:
636 | """ Create a new node allocation.
637 |
638 | node: :class:`int`
639 | The ID of the node.
640 | ip: :class:`str`
641 | The IP for the allocation.
642 | ports: :class:`list[str]`
643 | A list of ports or port ranges for the allocation.
644 | alias: :class:`str`
645 | Alias name of the allocation.
646 | """
647 | await self._http.post(
648 | f'/nodes/{node}/allocations',
649 | {
650 | 'ip': ip,
651 | 'alias': alias,
652 | 'ports': ports
653 | })
654 |
655 | def delete_node_allocation(self, node: int, _id: int) -> None:
656 | """Deletes an allocation from a node.
657 |
658 | node: :class:`int`
659 | The ID of the node.
660 | id: :class:`int`
661 | The ID of the allocation.
662 | """
663 | return self._http.delete(f'/nodes/{node}/allocations/{_id}')
664 |
665 | async def get_locations(self) -> list[Location]:
666 | data = await self._http.get('/locations')
667 | return [Location(**datum['attributes']) for datum in data['data']]
668 |
669 | async def get_location(self, _id: int) -> Location:
670 | data = await self._http.get(f'/locations/{_id}')
671 | return Location(**data['attributes'])
672 |
673 | async def create_location(self, *, short: str, long: str) -> Location:
674 | data = await self._http.post('/locations',
675 | {'short': short, 'long': long})
676 |
677 | return Location(**data['attributes'])
678 |
679 | async def update_location(
680 | self,
681 | _id: int,
682 | *,
683 | short: str = None,
684 | long: str = None
685 | ) -> Location:
686 | old = await self.get_location(_id)
687 | data = await self._http.patch(
688 | f'/locations/{_id}',
689 | {
690 | 'short': short or old.short,
691 | 'long': long or old.long
692 | })
693 |
694 | return Location(**data['attributes'])
695 |
696 | def delete_location(self, _id: int, /) -> None:
697 | return self._http.delete(f'/locations/{_id}')
698 |
699 | async def get_nests(self) -> list[Nest]:
700 | data = await self._http.get('/nests')
701 | return [Nest(**datum['attributes']) for datum in data['data']]
702 |
703 | async def get_nest(self, nest: int) -> Nest:
704 | data = await self._http.get(f'/nests/{nest}')
705 | return Nest(**data['attributes'])
706 |
707 | async def get_nest_eggs(self, nest: int, include: list[str] = None) -> list[Egg]:
708 | """ Retrieves a list of eggs.
709 |
710 | nest: :class:`int`
711 | The ID of the nest.
712 | include: Optional[list[:class:`str`]]
713 | A list of additional resources to include (default is ``None``).
714 | This supports:
715 | * nest
716 | * servers
717 | * config
718 | * script
719 | * variable
720 | """
721 | data = await self._http.get(f'/nests/{nest}/eggs', include=include)
722 | return [Egg(**datum['attributes']) for datum in data['data']]
723 |
724 | async def get_nest_egg(self, nest: int, _id: int, include: list[str] = None) -> Egg:
725 | """ Retrieves the specified eggs.
726 |
727 | nest: :class:`int`
728 | The ID of the nest.
729 | _id: :class:`int`
730 | The ID of the egg.
731 | include: Optional[list[:class:`str`]]
732 | A list of additional resources to include (default is ``None``).
733 | This supports:
734 | * nest
735 | * servers
736 | * config
737 | * script
738 | * variable
739 | """
740 | data = await self._http.get(f'/nests/{nest}/eggs/{_id}', include=include)
741 | return Egg(**data['attributes'])
742 |
--------------------------------------------------------------------------------
/pytero/client.py:
--------------------------------------------------------------------------------
1 | """The main class/interface for interacting with the Pterodactyl client
2 | API.
3 |
4 | This only supports client API keys, NOT application API keys, found at:
5 | https://your.pterodactyl.domain/account/api.
6 | """
7 |
8 | # pylint: disable=R0904
9 |
10 | from typing import Any
11 | from .files import Directory, File
12 | from .http import RequestManager
13 | from .permissions import Permissions
14 | from .types import APIKey, Activity, Backup, ClientDatabase, ClientVariable, \
15 | NetworkAllocation, SSHKey, Statistics, Task, WebSocketAuth
16 | from .schedules import Schedule
17 | from .servers import ClientServer
18 | from .shard import Shard
19 | from .users import Account, SubUser
20 |
21 |
22 | __all__ = ('PteroClient',)
23 |
24 |
25 | class PteroClient:
26 | """A class/interface for interacting with the client API.
27 |
28 | url: :class:`str`
29 | The URL of the Pterodactyl domain. This must be an absolute URL, not
30 | one that contains paths (trailing forward slash is allowed).
31 | key: :class:`str`
32 | The API key to use for HTTP requests. This must be a client API key,
33 | NOT an application API key.
34 | """
35 |
36 | def __init__(self, url: str, key: str) -> None:
37 | self.url = url.removesuffix('/')
38 | self.key = key
39 | self._http = RequestManager('client', self.url, key)
40 |
41 | def __repr__(self) -> str:
42 | return ''
43 |
44 | @property
45 | def event(self):
46 | """A decorator shorthand function for :meth:`RequestManager#event`."""
47 | return self._http.event
48 |
49 | async def get_permission_keys(self) -> dict[str, Any]:
50 | """Returns a dict containing the permission keys, values and
51 | descriptions for the client API."""
52 | data = await self._http.get('/permissions')
53 | return data['attributes']
54 |
55 | async def get_account(self) -> Account:
56 | """Returns an account object for the user associated with the API key
57 | being used."""
58 | data = await self._http.get('/account')
59 | return Account(self, data['attributes'])
60 |
61 | async def get_account_two_factor(self) -> dict[str, str]:
62 | """Returns a dict containing the two-factor authentication details."""
63 | data = await self._http.get('/account/two-factor')
64 | return data['data']
65 |
66 | async def enable_account_two_factor(self, code: int, /) -> list[str]:
67 | """Enables two-factor authentication for the user associated with the
68 | API key.
69 |
70 | code: :class:`int`
71 | The TOTP code generated with the authentication details.
72 | """
73 | data = await self._http.post('/account/two-factor', {'code': code})
74 | return data['attributes']['tokens']
75 |
76 | def disable_account_two_factor(self, password: str, /) -> None:
77 | """Disables two-factor authentication for the user associated with the
78 | API key.
79 |
80 | password: :class:`str`
81 | The password for the account associated with the API key.
82 | """
83 | return self._http.delete('/account/two-factor',
84 | body={'password': password})
85 |
86 | def update_account_email(self, email: str, password: str) -> None:
87 | """Updates the email for the user account associated with the API key.
88 |
89 | email: :class:`str`
90 | The new email for the account.
91 | password: :class:`str`
92 | The password for the account.
93 | """
94 | return self._http.put('/account/email', {'email': email,
95 | 'password': password})
96 |
97 | def update_account_password(self, old: str, new: str) -> None:
98 | """Updates the password for the user account associated with the API
99 | key.
100 |
101 | old: :class:`str`
102 | The old password of the account.
103 | new: :class:`str`
104 | The new password for the account.
105 | """
106 | return self._http.put('/account/password',
107 | {
108 | 'current_password': old,
109 | 'new_password': new,
110 | 'password_confirmation': new
111 | })
112 |
113 | async def get_account_activities(self) -> list[Activity]:
114 | data = await self._http.get('/account/activity')
115 | return [Activity(**datum['attributes']) for datum in data['data']]
116 |
117 | async def get_api_keys(self) -> list[APIKey]:
118 | data = await self._http.get('/account/api-keys')
119 | return [APIKey(**datum['attributes']) for datum in data['data']]
120 |
121 | async def create_api_key(
122 | self,
123 | *,
124 | description: str,
125 | allowed_ips: list[str] = None
126 | ) -> APIKey:
127 | data = await self._http.post(
128 | '/account/api-keys',
129 | {'description': description, 'allowed_ips': allowed_ips or []})
130 |
131 | return APIKey(**data['attributes'])
132 |
133 | def delete_api_key(self, identifier: str, /) -> None:
134 | return self._http.delete(f'/account/api-keys/{identifier}')
135 |
136 | async def get_ssh_keys(self) -> list[SSHKey]:
137 | data = await self._http.get('/account/ssh-keys')
138 | return [SSHKey(**datum['attributes']) for datum in data['data']]
139 |
140 | async def create_ssh_key(self, *, name: str, public_key: str) -> SSHKey:
141 | data = await self._http.post('/account/ssh-keys',
142 | {'name': name, 'public_key': public_key})
143 |
144 | return SSHKey(**data['attributes'])
145 |
146 | def remove_ssh_key(self, fingerprint: str, /) -> None:
147 | return self._http.post('/account/ssh-keys/remove',
148 | {'fingerprint': fingerprint})
149 |
150 | async def get_servers(self) -> list[ClientServer]:
151 | data = await self._http.get('/')
152 | return [ClientServer(self._http, datum['attributes'])
153 | for datum in data['data']]
154 |
155 | async def get_server(self, identifier: str, /) -> ClientServer:
156 | data = await self._http.get(f'/servers/{identifier}')
157 | return ClientServer(self._http, data['attributes'])
158 |
159 | async def get_server_ws(self, identifier: str, /) -> WebSocketAuth:
160 | data = await self._http.get(f'/servers/{identifier}/websocket')
161 | return WebSocketAuth(**data['data'])
162 |
163 | def create_shard(self, identifier: str, /) -> Shard:
164 | return Shard(self._http, identifier)
165 |
166 | async def get_server_resources(self, identifier: str, /) -> Statistics:
167 | data = await self._http.get(f'/servers/{identifier}/resources')
168 | return Statistics(**data['attributes'])
169 |
170 | async def get_server_activities(self,
171 | identifier: str, /) -> list[Activity]:
172 | data = await self._http.get(f'/servers/{identifier}/activity')
173 | return [Activity(**datum['attributes']) for datum in data['data']]
174 |
175 | def send_server_command(self, identifier: str, command: str) -> None:
176 | return self._http.post(f'/servers/{identifier}/command',
177 | {'command': command})
178 |
179 | def send_server_power(self, identifier: str, state: str) -> None:
180 | return self._http.post(f'/servers/{identifier}/power',
181 | {'signal': state})
182 |
183 | async def get_server_databases(self,
184 | identifier: str, /) -> list[ClientDatabase]:
185 | data = await self._http.get(f'/servers/{identifier}/databases')
186 | return [ClientDatabase(**datum['attributes'])
187 | for datum in data['data']]
188 |
189 | async def create_server_database(
190 | self,
191 | identifier: str,
192 | *,
193 | database: str,
194 | remote: str
195 | ) -> ClientDatabase:
196 | data = await self._http.post(f'/servers/{identifier}/databases',
197 | {'database': database, 'remote': remote})
198 |
199 | return ClientDatabase(**data['attributes'])
200 |
201 | async def rotate_database_password(self, identifier: str,
202 | _id: str) -> ClientDatabase:
203 | data = await self._http.post(
204 | f'/servers/{identifier}/databases/{_id}/rotate-password', None)
205 |
206 | return ClientDatabase(**data['attributes'])
207 |
208 | def delete_server_database(self, identifier: str, _id: str) -> None:
209 | return self._http.delete(f'/servers/{identifier}/databases/{_id}')
210 |
211 | def get_directory(self, identifier: str, _dir: str) -> Directory:
212 | return Directory(self._http, identifier, _dir)
213 |
214 | def get_server_files(self, identifier: str, _dir: str = '/') -> list[File]:
215 | return Directory(self._http, identifier, _dir).get_files()
216 |
217 | def get_server_file_dirs(
218 | self,
219 | identifier: str,
220 | root: str = '/'
221 | ) -> list[Directory]:
222 | return Directory(self._http, identifier, root).get_directories()
223 |
224 | async def get_server_schedules(self, identifier: str, /) -> list[Schedule]:
225 | data = await self._http.get(f'/servers/{identifier}/schedules')
226 | return [Schedule(self._http, identifier, datum['attributes'])
227 | for datum in data['data']]
228 |
229 | async def get_server_schedule(self, identifier: str, _id: int) -> Schedule:
230 | data = await self._http.get(f'/servers/{identifier}/schedules/{_id}')
231 | return Schedule(self._http, identifier, data['attributes'])
232 |
233 | async def create_server_schedule(
234 | self,
235 | identifier: str,
236 | *,
237 | name: str,
238 | is_active: bool,
239 | minute: str,
240 | hour: str,
241 | day_of_week: str,
242 | day_of_month: str
243 | ) -> Schedule:
244 | data = await self._http.post(
245 | f'/servers/{identifier}/schedules',
246 | {
247 | 'name': name,
248 | 'is_active': is_active,
249 | 'minute': minute,
250 | 'hour': hour,
251 | 'day_of_week': day_of_week,
252 | 'day_of_month': day_of_month
253 | })
254 |
255 | return Schedule(self._http, identifier, data['attributes'])
256 |
257 | async def update_server_schedule(
258 | self,
259 | identifier: str,
260 | _id: int,
261 | *,
262 | name: str = None,
263 | is_active: bool = False,
264 | minute: str = None,
265 | hour: str = None,
266 | month: str = None,
267 | day_of_week: str = None,
268 | day_of_month: str = None,
269 | only_when_online: bool = False
270 | ) -> Schedule:
271 | old = await self.get_server_schedule(identifier, _id)
272 | name = name or old.name
273 | is_active = is_active if is_active is not None else old.is_active
274 | minute = minute or old.cron.minute
275 | hour = hour or old.cron.hour
276 | month = month or old.cron.month
277 | day_of_week = day_of_week or old.cron.day_of_week
278 | day_of_month = day_of_month or old.cron.day_of_month
279 | only_when_online = only_when_online \
280 | if only_when_online is not None \
281 | else old.only_when_online
282 |
283 | data = await self._http.post(
284 | f'/servers/{identifier}/schedules/{_id}',
285 | {
286 | 'name': name,
287 | 'is_active': is_active,
288 | 'minute': minute,
289 | 'hour': hour,
290 | 'month': month,
291 | 'day_of_week': day_of_week,
292 | 'day_of_month': day_of_month,
293 | 'only_when_online': only_when_online
294 | })
295 |
296 | return Schedule(self._http, identifier, data['attributes'])
297 |
298 | def execute_server_schedule(self, identifier: str, _id: int) -> None:
299 | return self._http.post(
300 | f'/servers/{identifier}/schedules/{_id}/execute', None)
301 |
302 | def delete_server_schedule(self, identifier: str, _id: int) -> None:
303 | return self._http.delete(f'/servers/{identifier}/schedules/{_id}')
304 |
305 | async def get_schedule_tasks(self, identifier: str,
306 | _id: int) -> list[Task]:
307 | data = await self._http.get(
308 | f'/servers/{identifier}/schedules/{_id}/tasks')
309 |
310 | return [Task(**datum['attributes']) for datum in data['data']]
311 |
312 | async def create_schedule_task(
313 | self,
314 | identifier: str,
315 | _id: int,
316 | *,
317 | action: str,
318 | payload: str,
319 | time_offset: int,
320 | sequence_id: int = None,
321 | continue_on_failure: bool = False
322 | ) -> Task:
323 | data = await self._http.post(
324 | f'/servers/{identifier}/schedules/{_id}/tasks',
325 | {
326 | 'action': action,
327 | 'payload': payload,
328 | 'time_offset': time_offset,
329 | 'sequence_id': sequence_id,
330 | 'continue_on_failure': continue_on_failure
331 | })
332 |
333 | return Task(**data['attributes'])
334 |
335 | async def update_schedule_task(
336 | self,
337 | identifier: str,
338 | _id: int,
339 | tid: int,
340 | *,
341 | action: str,
342 | payload: str,
343 | time_offset: int,
344 | continue_on_failure: bool = False
345 | ) -> Task:
346 | data = await self._http.post(
347 | f'/servers/{identifier}/schedules/{_id}/tasks/{tid}',
348 | {
349 | 'action': action,
350 | 'payload': payload,
351 | 'time_offset': time_offset,
352 | 'continue_on_failure': continue_on_failure
353 | })
354 |
355 | return Task(**data['attributes'])
356 |
357 | def delete_schedule_task(
358 | self,
359 | identifier: str,
360 | _id: int,
361 | tid: int
362 | ) -> None:
363 | return self._http.delete(
364 | f'/servers/{identifier}/schedules/{_id}/tasks/{tid}')
365 |
366 | async def get_server_allocations(
367 | self,
368 | identifier: str,
369 | /
370 | ) -> list[NetworkAllocation]:
371 | data = await self._http.get(
372 | f'/servers/{identifier}/network/allocations')
373 | return [NetworkAllocation(**datum['attributes'])
374 | for datum in data['data']]
375 |
376 | async def create_server_allocation(self, identifier: str, /) \
377 | -> NetworkAllocation:
378 | data = await self._http.post(
379 | f'/servers/{identifier}/network/allocations', None)
380 |
381 | return NetworkAllocation(**data['attributes'])
382 |
383 | async def set_server_allocation_notes(
384 | self,
385 | identifier: str,
386 | _id: int,
387 | notes: str
388 | ) -> NetworkAllocation:
389 | data = await self._http.post(
390 | f'/servers/{identifier}/network/allocations/{_id}',
391 | {'notes': notes})
392 |
393 | return NetworkAllocation(**data['attributes'])
394 |
395 | async def set_server_primary_allocation(
396 | self,
397 | identifier: str,
398 | _id: int
399 | ) -> NetworkAllocation:
400 | data = await self._http.post(
401 | f'/servers/{identifier}/network/allocations/{_id}/primary', None)
402 |
403 | return NetworkAllocation(**data['attributes'])
404 |
405 | def delete_server_allocation(self, identifier: str, _id: int) -> None:
406 | return self._http.delete(
407 | f'/servers/{identifier}/network/allocations/{_id}')
408 |
409 | async def get_server_subusers(self, identifier: str, /) -> list[SubUser]:
410 | data = await self._http.get(f'/servers/{identifier}/users')
411 | return [SubUser(self._http, datum['attributes'])
412 | for datum in data['data']]
413 |
414 | async def get_server_subuser(self, identifier: str, uuid: str) -> SubUser:
415 | data = await self._http.get(f'/servers/{identifier}/users/{uuid}')
416 | return SubUser(self._http, data['attributes'])
417 |
418 | async def add_server_subuser(self, identifier: str, email: str) -> SubUser:
419 | data = await self._http.post(f'/servers/{identifier}/users',
420 | {'email': email})
421 |
422 | return SubUser(self._http, data['attributes'])
423 |
424 | async def update_subuser_permissions(
425 | self,
426 | identifier: str,
427 | uuid: str,
428 | permissions: Permissions
429 | ) -> SubUser:
430 | data = await self._http.post(f'/servers/{identifier}/users/{uuid}',
431 | {'permissions': permissions.value})
432 |
433 | return SubUser(self._http, data['attributes'])
434 |
435 | def remove_server_subuser(self, identifier: str, uuid: str) -> None:
436 | return self._http.delete(f'/servers/{identifier}/users/{uuid}')
437 |
438 | async def list_backups(self, identifier: str, /) -> list[Backup]:
439 | data = await self._http.get(f'/servers/{identifier}/backups')
440 | return [Backup(**datum['attributes']) for datum in data['data']]
441 |
442 | async def create_backup(self, identifier: str, *, name: str | None = None,
443 | ignore_files: list[str] | None = None,
444 | locked: bool = False) -> Backup:
445 | data = await self._http.post(f'/servers/{identifier}/backups',
446 | {"name": name,
447 | "ignore_files": ignore_files,
448 | "locked": locked})
449 |
450 | return Backup(**data['attributes'])
451 |
452 | async def get_backup(self, identifier: str, uuid: str) -> Backup:
453 | data = await self._http.get(f'/servers/{identifier}/backups/{uuid}')
454 | return Backup(**data['attributes'])
455 |
456 | async def get_backup_download_url(self, identifier: str, uuid: str) -> str:
457 | data = await self._http.get(
458 | f'/servers/{identifier}/backups/{uuid}/download')
459 |
460 | return data['attributes']['url']
461 |
462 | def delete_backup(self, identifier: str, uuid: str) -> None:
463 | return self._http.delete(f'/servers/{identifier}/backups/{uuid}')
464 |
465 | async def get_server_startup(self,
466 | identifier: str) -> list[ClientVariable]:
467 | data = await self._http.get(f'/servers/{identifier}/startup')
468 | return [ClientVariable(**datum['attributes'])
469 | for datum in data['data']]
470 |
471 | async def set_server_variable(self, identifier: str, key: str,
472 | value: int | str | bool) -> ClientVariable:
473 | data = await self._http.put(f'/servers/{identifier}/startup/variable',
474 | {'key': key, 'value': value})
475 |
476 | return ClientVariable(**data['attributes'])
477 |
478 | def rename_server(self, identifier: str, name: str) -> None:
479 | return self._http.post(f'/servers/{identifier}/settings/rename',
480 | {'name': name})
481 |
482 | def reinstall_server(self, identifier: str, /) -> None:
483 | return self._http.post(
484 | f'/servers/{identifier}/settings/reinstall', None)
485 |
486 | def set_server_docker_image(self, identifier: str, image: str) -> None:
487 | return self._http.put(f'/servers/{identifier}/settings/docker-image',
488 | {'docker_image': image})
489 |
--------------------------------------------------------------------------------
/pytero/errors.py:
--------------------------------------------------------------------------------
1 | """Error definitions for Pytero."""
2 |
3 | __all__ = (
4 | 'AccessError',
5 | 'EventError',
6 | 'PteroAPIError',
7 | 'RangeError',
8 | 'RequestError',
9 | 'ShardError',
10 | 'ValidationError'
11 | )
12 |
13 |
14 | class AccessError(Exception):
15 | """Raised when a resource cannot/should not be accessed yet"""
16 |
17 | def __init__(self, cls) -> None:
18 | super().__init__(
19 | f'resources for {cls.__class__.__name__} are not available')
20 |
21 |
22 | class EventError(Exception):
23 | """Raised when there is an error with an event"""
24 |
25 |
26 | class PteroAPIError(Exception):
27 | """The error object received from Pterodactyl when there is an error"""
28 |
29 | def __init__(
30 | self,
31 | message: str,
32 | data: dict[str, list[dict[str, str]]]) -> None:
33 | super().__init__(message)
34 | self.codes: dict[int, str] = {}
35 | self.details: dict[int, str] = {}
36 | self.statuses: dict[int, str] = {}
37 |
38 | for i in range(len(data['errors'])):
39 | self.codes[i] = data['errors'][i]['code']
40 | self.details[i] = data['errors'][i]['detail']
41 | self.statuses[i] = data['errors'][i]['status']
42 |
43 | def __getitem__(self, index: int) -> dict[str, str]:
44 | err: dict[str, int | str] = {}
45 | err['code'] = self.codes[index]
46 | err['detail'] = self.details[index]
47 | err['status'] = self.statuses[index]
48 |
49 | return err
50 |
51 | def __iter__(self):
52 | for i in range(len(self.codes)):
53 | yield self[i]
54 |
55 |
56 | class RangeError(Exception):
57 | """Errors received for invalid ranges"""
58 |
59 |
60 | class RequestError(Exception):
61 | """Errors received upon requesting a Pterodactyl endpoint"""
62 |
63 |
64 | class ShardError(Exception):
65 | """Raised when a shard authentication or connection fails"""
66 |
67 |
68 | class ValidationError(Exception):
69 | """Errors received when a request validation fails"""
70 |
--------------------------------------------------------------------------------
/pytero/events.py:
--------------------------------------------------------------------------------
1 | """A basic event emitter implementation for Pytero, specfically for the HTTP
2 | and websocket interactions/events.
3 | """
4 |
5 | from inspect import iscoroutinefunction
6 | from typing import Callable
7 | from .errors import EventError
8 |
9 |
10 | __all__ = ('Emitter',)
11 |
12 |
13 | class Emitter:
14 | """Events emitter manager for Pytero"""
15 |
16 | def __init__(self) -> None:
17 | self._slots: dict[str, tuple[bool, Callable[..., None]]] = {}
18 |
19 | def __repr__(self) -> str:
20 | return f''
21 |
22 | def add_event(self, name: str, slot: Callable[..., None]) -> None:
23 | """Adds a callback function for a specified event. Note that the
24 | function can be synchronous or asynchronous as both will be handled
25 | when emitted.
26 |
27 | name: :class:`str`
28 | The name of the event.
29 | slot: Callable[..., None]
30 | The callback function.
31 | """
32 | if not callable(slot):
33 | raise TypeError('event slot is not a function')
34 |
35 | self._slots[name] = (iscoroutinefunction(slot), slot)
36 |
37 | def remove_event(self, name: str, /) -> None:
38 | """Removes a callback function for a specified event.
39 |
40 | name: :class:`str`
41 | The name of the event.
42 | """
43 | del self._slots[name]
44 |
45 | def has_event(self, name: str, /) -> bool:
46 | """Returns ``True`` if the specified event has a callback function.
47 |
48 | name: :class:`str`
49 | The name of the event.
50 | """
51 | return name in self._slots
52 |
53 | def clear_slots(self) -> None:
54 | """Clears all the events and callback functions."""
55 | self._slots.clear()
56 |
57 | async def emit_event(self, name: str, *args, **kwargs) -> None:
58 | """Emits an event callback with the given arguments.
59 |
60 | name: :class:`str`
61 | The name of the event.
62 | args: Any
63 | A tuple of arguments to be passed on to the event callback.
64 | kwargs: Any
65 | A dict of arguments to be passed on to the event callback.
66 | """
67 | if slot := self._slots.get(name):
68 | try:
69 | if slot[0]:
70 | await slot[1](*args, **kwargs)
71 | else:
72 | slot[1](*args, **kwargs)
73 | except Exception as ex:
74 | raise EventError(f'failed to run event: {ex}') from ex
75 |
--------------------------------------------------------------------------------
/pytero/files.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Optional
2 | from .types import _Http
3 |
4 |
5 | __all__ = ('File', 'Directory')
6 |
7 |
8 | class File:
9 | def __init__(
10 | self,
11 | http: _Http,
12 | identifier: str,
13 | root: str,
14 | data: dict[str, Any]
15 | ) -> None:
16 | self._http = http
17 | self.identifier = identifier
18 | self.__name: str = data['name']
19 | if root != '/':
20 | root += '/'
21 |
22 | self.__path: str = root + data['name']
23 | self.mode: str = data['mode']
24 | self.mode_bits: int = int(data['mode_bits'])
25 | self.size: int = int(data['size'])
26 | self.is_file: bool = data['is_file']
27 | self.is_symlink: bool = data['is_symlink']
28 | self.mimetype: str = data['mimetype']
29 | self.created_at: str = data['created_at']
30 | self.modified_at: Optional[str] = data.get('modified_at')
31 |
32 | def __repr__(self) -> str:
33 | return f''
34 |
35 | def __str__(self) -> str:
36 | return self.__name
37 |
38 | @property
39 | def name(self) -> str:
40 | return self.__name
41 |
42 | @property
43 | def path(self) -> str:
44 | return self.__path
45 |
46 | @property
47 | def root(self) -> str:
48 | root = '/'.join(self.path.split('/')[:-1])
49 | if root == '':
50 | return '/'
51 |
52 | return root
53 |
54 | async def get_contents(self) -> str:
55 | return await self._http.get(
56 | f'/servers/{self.identifier}/files/contents?file={self.__path}',
57 | ctype='text/plain')
58 |
59 | async def get_download_url(self) -> str:
60 | data = await self._http.get(
61 | f'/servers/{self.identifier}/files/download?file={self.__path}')
62 |
63 | return data['attributes']['url']
64 |
65 | async def download_to(self, dest: str, /) -> None:
66 | file = open(dest, 'xb')
67 | url = await self.get_download_url()
68 |
69 | # TODO: resolve this
70 | dl = await self._http._raw('GET', url, ctype='text/plain')
71 | file.write(bytes(dl, 'utf-8'))
72 | file.close()
73 |
74 | async def rename(self, name: str, /) -> None:
75 | await self._http.put(
76 | f'/servers/{self.identifier}/files/rename',
77 | {
78 | 'root': self.root,
79 | 'files': [{
80 | 'from': self.__name,
81 | 'to': name
82 | }]
83 | })
84 |
85 | self.__name = name
86 |
87 | async def copy_to(self, location: str, /) -> None:
88 | await self._http.post(f'/servers/{self.identifier}/files/copy',
89 | {'location': location})
90 |
91 | async def write(self, data: str, /) -> None:
92 | await self._http.post(
93 | f'/servers/{self.identifier}/files/write?file={self.__path}',
94 | ctype='text/plain',
95 | body=bytes(data, 'utf-8'))
96 |
97 | async def compress(self):
98 | data = await self._http.post(
99 | f'/servers/{self.identifier}/files/compress',
100 | {'root': self.root, 'files': [self.__name]})
101 |
102 | return File(self._http, self.identifier, self.__path,
103 | data['attributes'])
104 |
105 | async def decompress(self) -> None:
106 | await self._http.post(f'/servers/{self.identifier}/files/decompress',
107 | {'root': self.root, 'file': self.__name})
108 |
109 | async def delete(self) -> None:
110 | await self._http.post(f'/servers/{self.identifier}/files/delete',
111 | {'root': self.root, 'files': [self.__name]})
112 |
113 |
114 | class Directory:
115 | def __init__(self, http: _Http, identifier: str, path: str) -> None:
116 | self._http = http
117 | self.identifier = identifier
118 | self.__path = path
119 |
120 | def __repr__(self) -> str:
121 | return f''
122 |
123 | def __str__(self) -> str:
124 | return self.__path
125 |
126 | @property
127 | def path(self) -> str:
128 | return self.__path
129 |
130 | async def get_files(self) -> list[File]:
131 | data = await self._http.get(
132 | f'/servers/{self.identifier}/files/list?directory={self.__path}')
133 |
134 | res: list[File] = []
135 |
136 | for datum in data['data']:
137 | if datum['attributes']['mimetype'] == 'inode/directory':
138 | continue
139 |
140 | res.append(File(
141 | self._http,
142 | self.identifier,
143 | self.__path,
144 | datum['attributes']))
145 |
146 | return res
147 |
148 | async def get_directories(self):
149 | data = await self._http.get(
150 | f'/servers/{self.identifier}/files/list?directory={self.__path}')
151 |
152 | res: list[Directory] = []
153 |
154 | for datum in data['data']:
155 | if datum['attributes']['mimetype'] == 'inode/directory':
156 | path = self._clean(self.__path + datum['attributes']['name'])
157 | res.append(Directory(self._http, self.identifier, path))
158 |
159 | return res
160 |
161 | def _clean(self, path: str, /) -> str:
162 | if 'home/directory' in path:
163 | path = path.replace('home/directory', '')
164 |
165 | return '/' + path.replace('\\', '/').removeprefix('/').removesuffix('/') # noqa: E501
166 |
167 | def into_dir(self, _dir: str, /):
168 | path = self._clean(self.__path + _dir)
169 | return Directory(self._http, self.identifier, path)
170 |
171 | def back_dir(self, _dir: str, /):
172 | path = self._clean(self.__path + _dir)
173 | if '..' in path:
174 | path = path.split('..')[0]
175 |
176 | return Directory(self._http, self.identifier, path)
177 |
178 | async def rename_all(self, files: list[dict[str, str]]) -> None:
179 | parsed = list(filter(lambda d: 'from' in d and 'to' in d, files))
180 | if len(parsed) == 0:
181 | raise SyntaxError('no files with from and to keys found')
182 |
183 | await self._http.post(f'/servers/{self.identifier}/files/rename',
184 | {'root': self.__path, 'files': files})
185 |
186 | async def create_dir(self, name: str, /):
187 | await self._http.post(
188 | f'/servers/{self.identifier}/files/create-folder',
189 | {'root': self.__path, 'name': name})
190 |
191 | return Directory(self._http, self.identifier,
192 | self._clean(self.__path + name))
193 |
194 | async def delete_dir(self, name: str, /) -> None:
195 | await self._http.post(f'/servers/{self.identifier}/files/delete',
196 | {'root': self.__path, 'files': [name]})
197 |
198 | async def delete_all(self, files: list[dict[str, str]], /) -> None:
199 | parsed = list(filter(lambda d: 'from' in d and 'to' in d, files))
200 | if len(parsed) == 0:
201 | raise SyntaxError('no files with from and to keys found')
202 |
203 | await self._http.post(f'/servers/{self.identifier}/files/delete',
204 | {'root': self.__path, 'files': files})
205 |
206 | async def delete(self) -> None:
207 | await self.delete_dir(self.__path)
208 |
209 | async def pull_file(
210 | self,
211 | url: str,
212 | *,
213 | directory: str = None,
214 | filename: str = None,
215 | use_header: bool = False,
216 | foreground: bool = False
217 | ) -> None:
218 | if directory is None:
219 | directory = self.__path
220 |
221 | await self._http.post(
222 | f'/servers/{self.identifier}/files/pull',
223 | {
224 | 'url': url,
225 | 'directory': directory,
226 | 'filename': filename,
227 | 'use_header': use_header,
228 | 'foreground': foreground
229 | })
230 |
231 | async def get_upload_url(self) -> str:
232 | data = await self._http.get(f'/servers/{self.identifier}/files/upload')
233 | return data['attributes']['url']
234 |
--------------------------------------------------------------------------------
/pytero/http.py:
--------------------------------------------------------------------------------
1 | from json import dumps
2 | from sys import getsizeof
3 | from time import time
4 | from typing import Any, Callable
5 | from aiohttp import ClientSession, ClientResponse
6 | from .errors import PteroAPIError, RequestError
7 | from .events import Emitter
8 |
9 |
10 | __all__ = ('RequestManager',)
11 |
12 |
13 | class RequestManager(Emitter):
14 | def __init__(self, api: str, url: str, key: str) -> None:
15 | super().__init__()
16 | self._api = api
17 | self.url = url
18 | self.key = key
19 | self.ping: float = float('nan')
20 |
21 | def __repr__(self) -> str:
22 | return ''
23 |
24 | def event(self, func: Callable[[str], None]) -> Callable[[str], None]:
25 | super().add_event(func.__name__, func)
26 | return func
27 |
28 | async def _emit(self, name: str, *args):
29 | for arg in args:
30 | await super().emit_event(name, arg)
31 |
32 | def headers(self, ctype: str) -> dict[str, str]:
33 | return {
34 | 'User-Agent': f'{self._api.title()} Pytero v0.1.0',
35 | 'Content-Type': ctype,
36 | 'Accept': 'application/json,text/plain',
37 | 'Authorization': f'Bearer {self.key}'}
38 |
39 | def _validate_query(self, args: dict[str, Any]) -> str:
40 | query: list[str] = []
41 | if _filter := args.get('_filter'):
42 | query.append(f'filter[{_filter[0]}]={_filter[1]}')
43 |
44 | if include := args.get('include'):
45 | query.append('include=' + ','.join(include))
46 |
47 | if sort := args.get('sort'):
48 | query.append(f'sort={sort}')
49 |
50 | if page := args.get('page'):
51 | query.append(f'page={page}')
52 |
53 | if per_page := args.get('per_page'):
54 | query.append(f'per_page={per_page}')
55 |
56 | if extra := args.get('extra'):
57 | for k in extra:
58 | query.append(f'{k}={extra[k]}')
59 |
60 | if len(query) == 0:
61 | return ''
62 |
63 | return '?' + query[0] + '&'.join(query[1:])
64 |
65 | async def _make(self, method: str, path: str, **kwargs):
66 | if method not in ('GET', 'POST', 'PATCH', 'PUT', 'DELETE'):
67 | raise KeyError(f"invalid http method '{method}'")
68 |
69 | payload = None
70 | body = kwargs.get('body') or None
71 | ctype = kwargs.get('ctype') or 'application/json'
72 |
73 | if body is not None:
74 | if ctype == 'application/json':
75 | payload = dumps(body)
76 | await super().emit_event('on_send', payload)
77 | else:
78 | payload = body
79 |
80 | query = self._validate_query(kwargs)
81 | url = f'{self.url}/api/{self._api}{path}/{query}'
82 | await self._emit(
83 | 'on_debug',
84 | f'request: {method} /api/{self._api}{path}',
85 | f'payload: {getsizeof(payload)} bytes')
86 |
87 | async with ClientSession() as session:
88 | start = time()
89 | async with getattr(session, method.lower())(
90 | url,
91 | data=payload,
92 | headers=self.headers(ctype)) as response:
93 | self.ping = time() - start
94 | response: ClientResponse
95 |
96 | await self._emit(
97 | 'on_debug',
98 | f'response: {response.status}',
99 | f'content-type: {response.content_type}',
100 | f'content-length: {response.content_length or 0}')
101 |
102 | if response.status == 204:
103 | return None
104 |
105 | if response.status in (200, 201, 202):
106 | if response.headers.get('content-type') == \
107 | 'application/json':
108 | data = await response.json()
109 | await super().emit_event('on_receive', data)
110 | return data
111 |
112 | data = await response.text()
113 | return data
114 |
115 | if 400 <= response.status < 500:
116 | data: dict[str, Any] = await response.json()
117 | await super().emit_event('on_error', data)
118 | raise PteroAPIError(data['errors'][0]['code'], data)
119 |
120 | raise RequestError(
121 | 'pterodactyl api returned an invalid or unacceptable'
122 | f' response (status: {response.status})')
123 |
124 | async def _raw(self, method: str, url: str, *, ctype: str, body=None):
125 | if method not in ('GET', 'POST', 'PATCH', 'PUT', 'DELETE'):
126 | raise KeyError(f"invalid http method '{method}'")
127 |
128 | headers = self.headers(ctype)
129 | del headers['Authorization']
130 |
131 | async with ClientSession() as session:
132 | async with getattr(session, method.lower())(
133 | url,
134 | data=body,
135 | headers=headers) as response:
136 | response: ClientResponse
137 |
138 | if response.status == 204:
139 | return None
140 |
141 | if response.status in (200, 201, 202):
142 | if response.headers.get('content-type') == \
143 | 'application/json':
144 | data = await response.json()
145 | await super().emit_event('on_receive', data)
146 | return data
147 |
148 | data = await response.text()
149 | return data
150 |
151 | if 400 <= response.status < 500:
152 | data: dict[str, Any] = await response.json()
153 | await super().emit_event('on_error', data)
154 | raise RequestError(data.get('error', 'unknown api error'))
155 |
156 | raise RequestError(
157 | 'pterodactyl api returned an invalid or unacceptable'
158 | f' response (status: {response.status})')
159 |
160 | def get(
161 | self,
162 | path: str,
163 | *,
164 | body=None,
165 | ctype: str = None,
166 | _filter: tuple[str, str] = None,
167 | include: list[str] = None,
168 | sort: str = None,
169 | page: int = None,
170 | per_page: int = None
171 | ):
172 | return self._make(
173 | 'GET',
174 | path,
175 | body=body,
176 | ctype=ctype,
177 | _filter=_filter,
178 | include=include,
179 | sort=sort,
180 | page=page,
181 | per_page=per_page)
182 |
183 | def post(
184 | self,
185 | path: str,
186 | body,
187 | *,
188 | ctype: str = None,
189 | _filter: tuple[str, str] = None,
190 | include: list[str] = None,
191 | sort: str = None,
192 | page: int = None,
193 | per_page: int = None
194 | ):
195 | return self._make(
196 | 'POST',
197 | path,
198 | body=body,
199 | ctype=ctype,
200 | _filter=_filter,
201 | include=include,
202 | sort=sort,
203 | page=page,
204 | per_page=per_page)
205 |
206 | def patch(
207 | self,
208 | path: str,
209 | body,
210 | *,
211 | ctype: str = None,
212 | _filter: tuple[str, str] = None,
213 | include: list[str] = None,
214 | sort: str = None,
215 | page: int = None,
216 | per_page: int = None
217 | ):
218 | return self._make(
219 | 'PATCH',
220 | path,
221 | body=body,
222 | ctype=ctype,
223 | _filter=_filter,
224 | include=include,
225 | sort=sort,
226 | page=page,
227 | per_page=per_page)
228 |
229 | def put(
230 | self,
231 | path: str,
232 | body,
233 | *,
234 | ctype: str = None,
235 | _filter: tuple[str, str] = None,
236 | include: list[str] = None,
237 | sort: str = None,
238 | page: int = None,
239 | per_page: int = None
240 | ):
241 | return self._make(
242 | 'PUT',
243 | path,
244 | body=body,
245 | ctype=ctype,
246 | _filter=_filter,
247 | include=include,
248 | sort=sort,
249 | page=page,
250 | per_page=per_page)
251 |
252 | def delete(
253 | self,
254 | path: str,
255 | *,
256 | body=None,
257 | ctype: str = None,
258 | _filter: tuple[str, str] = None,
259 | include: list[str] = None,
260 | sort: str = None,
261 | page: int = None,
262 | per_page: int = None
263 | ):
264 | return self._make(
265 | 'DELETE',
266 | path,
267 | body=body,
268 | ctype=ctype,
269 | _filter=_filter,
270 | include=include,
271 | sort=sort,
272 | page=page,
273 | per_page=per_page)
274 |
--------------------------------------------------------------------------------
/pytero/node.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 | from .servers import AppServer
3 | from .types import Allocation, Location, NodeConfiguration
4 | from .util import transform
5 |
6 | # pylint: disable=C0103
7 |
8 | __all__ = ('Node',)
9 |
10 |
11 | class Node:
12 | def __init__(self, http, data: dict[str, Any]) -> None:
13 | self._http = http
14 | self.id: int = data['id']
15 | self.created_at: str = data['created_at']
16 | self._patch(data)
17 | self._patch_relations(data.get('relationships'))
18 |
19 | def __repr__(self) -> str:
20 | return f''
21 |
22 | def __str__(self) -> str:
23 | return self.name
24 |
25 | def _patch(self, data: dict[str, Any]) -> None:
26 | self.name: str = data['name']
27 | self.description: str | None = data.get('description')
28 | self.location_id: int = data['location_id']
29 | self.location: Location | None = None
30 | self.allocations: list[Allocation] | None = None
31 | self.servers: list[AppServer] | None = None
32 | self.public: bool = data['public']
33 | self.fqdn: str = data['fqdn']
34 | self.scheme: str = data['scheme']
35 | self.behind_proxy: bool = data['behind_proxy']
36 | self.memory: int = data['memory']
37 | self.memory_overallocate: int = data['memory_overallocate']
38 | self.disk: int = data['disk']
39 | self.disk_overallocate: int = data['disk_overallocate']
40 | self.daemon_base: str = data['daemon_base']
41 | self.daemon_sftp: int = data['daemon_sftp']
42 | self.daemon_listen: int = data['daemon_listen']
43 | self.maintenance_mode: bool = data['maintenance_mode']
44 | self.upload_size: int = data['upload_size']
45 | self.updated_at: str | None = data.get('updated_at')
46 |
47 | def _patch_relations(self, data: dict[str, Any] | None) -> None:
48 | if data is None:
49 | return
50 |
51 | if 'allocations' in data:
52 | self.allocations = []
53 | for datum in data['allocations']['data']:
54 | self.allocations.append(Allocation(**datum['attributes']))
55 |
56 | if 'location' in data:
57 | self.location = Location(**data['location']['attributes'])
58 |
59 | if 'servers' in data:
60 | self.servers = []
61 | for datum in data['servers']['data']:
62 | self.servers.append(AppServer(self._http, datum['attributes']))
63 |
64 | def to_dict(self) -> dict[str, Any]:
65 | return transform(self, ignore=['_http'])
66 |
67 | async def get_configuration(self) -> NodeConfiguration:
68 | return await self._http.get_node_configuration(self.id)
69 |
70 | def update_node(self) -> None:
71 | return NotImplemented
72 |
--------------------------------------------------------------------------------
/pytero/permissions.py:
--------------------------------------------------------------------------------
1 | """Permissions definitions for the Pterodactyl API."""
2 |
3 | from enum import Enum
4 | from typing import TypeVar
5 |
6 |
7 | __all__ = ('Flags', 'Permissions')
8 |
9 | P = TypeVar('P', bound='Permissions')
10 |
11 |
12 | class Flags(Enum):
13 | """An enum class containing all the permission keys for the API."""
14 |
15 | WEBSOCKET_CONNECT = 'websocket.connect'
16 |
17 | CONTROL_CONSOLE = 'control.console'
18 | CONTROL_START = 'control.start'
19 | CONTROL_STOP = 'control.stop'
20 | CONTROL_RESTART = 'control.restart'
21 |
22 | USER_CREATE = 'user.create'
23 | USER_READ = 'user.read'
24 | USER_UPDATE = 'user.update'
25 | USER_DELETE = 'user.delete'
26 |
27 | FILE_CREATE = 'file.create'
28 | FILE_READ = 'file.read'
29 | FILE_READ_CONTENT = 'file.read-content'
30 | FILE_UPDATE = 'file.update'
31 | FILE_DELETE = 'file.delete'
32 | FILE_ARCHIVE = 'file.archive'
33 | FILE_SFTP = 'file.sftp'
34 |
35 | BACKUP_CREATE = 'backup.create'
36 | BACKUP_READ = 'backup.read'
37 | BACKUP_UPDATE = 'backup.update'
38 | BACKUP_DELETE = 'backup.delete'
39 |
40 | ALLOCATION_READ = 'allocation.read'
41 | ALLOCATION_CREATE = 'allocation.create'
42 | ALLOCATION_UPDATE = 'allocation.update'
43 | ALLOCATION_DELETE = 'allocation.delete'
44 |
45 | STARTUP_READ = 'startup.read'
46 | STARTUP_UPDATE = 'sartup.update'
47 |
48 | DATABASE_CREATE = 'database.create'
49 | DATABASE_READ = 'database.read'
50 | DATABASE_UPDATE = 'database.update'
51 | DATABASE_DELETE = 'database.delete'
52 | DATABASE_VIEW_PASSWORD = 'database.view_password'
53 |
54 | SCHEDULE_CREATE = 'schedule.create'
55 | SCHEDULE_READ = 'schedule.read'
56 | SCHEDULE_UPDATE = 'schedule.update'
57 | SCHEDULE_DELETE = 'schedule.delete'
58 |
59 | SETTINGS_RENAME = 'settings.rename'
60 | SETTINGS_REINSTALL = 'settings.reinstall'
61 |
62 | ADMIN_WEBSOCKET_ERRORS = 'admin.websocket.errors'
63 | ADMIN_WEBSOCKET_INSTALL = 'admin.websocket.install'
64 | ADMIN_WEBSOCKET_TRANSFER = 'admin.websocket.transfer'
65 |
66 | # FIX THIS IMMEDIATELY!!!
67 | def values(self) -> list[str]:
68 | return [p.value for p in Flags.__members__.values()]
69 |
70 |
71 | class Permissions:
72 | """The base class for managing permissions with the API."""
73 |
74 | ALL_CONSOLE = (
75 | Flags.CONTROL_CONSOLE,
76 | Flags.CONTROL_START,
77 | Flags.CONTROL_STOP,
78 | Flags.CONTROL_RESTART)
79 |
80 | ALL_USER = (
81 | Flags.USER_CREATE,
82 | Flags.USER_READ,
83 | Flags.USER_UPDATE,
84 | Flags.USER_DELETE)
85 |
86 | ALL_FILE = (
87 | Flags.FILE_CREATE,
88 | Flags.FILE_READ,
89 | Flags.FILE_READ_CONTENT,
90 | Flags.FILE_UPDATE,
91 | Flags.FILE_ARCHIVE,
92 | Flags.FILE_SFTP)
93 |
94 | ALL_BACKUP = (
95 | Flags.BACKUP_CREATE,
96 | Flags.BACKUP_READ,
97 | Flags.BACKUP_UPDATE,
98 | Flags.BACKUP_DELETE)
99 |
100 | ALL_ALLOCATION = (
101 | Flags.ALLOCATION_CREATE,
102 | Flags.ALLOCATION_READ,
103 | Flags.ALLOCATION_UPDATE,
104 | Flags.ALLOCATION_DELETE)
105 |
106 | ALL_STARTUP = (Flags.STARTUP_READ, Flags.STARTUP_UPDATE)
107 |
108 | ALL_DATABASE = (
109 | Flags.DATABASE_CREATE,
110 | Flags.DATABASE_READ,
111 | Flags.DATABASE_UPDATE,
112 | Flags.DATABASE_DELETE,
113 | Flags.DATABASE_VIEW_PASSWORD)
114 |
115 | ALL_SCHEDULE = (
116 | Flags.SCHEDULE_CREATE,
117 | Flags.SCHEDULE_READ,
118 | Flags.SCHEDULE_UPDATE,
119 | Flags.SCHEDULE_DELETE)
120 |
121 | ALL_SETTINGS = (Flags.SETTINGS_RENAME, Flags.SETTINGS_REINSTALL)
122 |
123 | ALL_ADMIN = (
124 | Flags.ADMIN_WEBSOCKET_ERRORS,
125 | Flags.ADMIN_WEBSOCKET_INSTALL,
126 | Flags.ADMIN_WEBSOCKET_TRANSFER)
127 |
128 | def __init__(self, *perms: str | Flags) -> None:
129 | self.value: list[str] = self.resolve(*perms)
130 |
131 | def __repr__(self) -> str:
132 | return f''
133 |
134 | def __len__(self) -> int:
135 | return len(self.value)
136 |
137 | def __bool__(self) -> bool:
138 | return len(self.value) != 0
139 |
140 | def __contains__(self, perm: str):
141 | return perm in self.value
142 |
143 | def __eq__(self, other: P) -> bool:
144 | return self.value == other.value
145 |
146 | def __add__(self, other: P) -> P:
147 | return Permissions(*(self.value + other.value))
148 |
149 | def __sub__(self, other: P) -> P:
150 | perms = list(filter(lambda p: p not in other.value, self.value))
151 | return Permissions(*perms)
152 |
153 | @staticmethod
154 | def resolve(*perms: str | Flags) -> list[str]:
155 | """Resolves the given permissions into a list of valid API permissions.
156 |
157 | perms: tuple[:class:`str` | :class:`Flags`]
158 | A tuple of strings or permission flags.
159 | """
160 | res: list[str] = []
161 | flags = Flags.values(Flags)
162 |
163 | for perm in perms:
164 | if isinstance(perm, Flags):
165 | res.append(perm.value)
166 | elif perm in flags:
167 | res.append(perm)
168 | else:
169 | raise KeyError(f"invalid permission or flag '{perm}'")
170 |
171 | return res
172 |
173 | def any(self, *perms: str | Flags) -> bool:
174 | """Returns ``True`` if any of the specified permissions exist in the
175 | permission instance.
176 |
177 | perms: tuple[:class:`str` | :class:`Flags`]
178 | A tuple of strings or permission flags.
179 | """
180 | res = self.__class__.resolve(*perms)
181 | return any(map(lambda p: p in self.value, res))
182 |
183 | def all(self, *perms: str | Flags) -> bool:
184 | """Returns ``True`` is all of the specified permissions exist in the
185 | permission instance.
186 |
187 | perms: tuple[:class:`str` | :class:`Flags`]
188 | A tuple of strings or permission flags.
189 | """
190 | res = self.__class__.resolve(*perms)
191 | return all(map(lambda p: p in self.value, res))
192 |
193 | def is_admin(self) -> bool:
194 | """Returns ``True`` if any of the permissions in the instance are
195 | administrative.
196 | """
197 | return any(filter(lambda p: 'admin' in p, self.value))
198 |
199 | def serialize(self) -> dict[str, bool]:
200 | """Returns a dict of permission keys mapping to their presence in the
201 | permission instance.
202 | """
203 | return {k: k in self.value for k in Flags.values(Flags)}
204 |
--------------------------------------------------------------------------------
/pytero/schedules.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 | from .types import _Http, Cron, Task
3 |
4 | # pylint: disable=C0103
5 |
6 | __all__ = ('Schedule',)
7 |
8 |
9 | class Schedule:
10 | def __init__(self, http: _Http, identifier: str,
11 | data: dict[str, Any]) -> None:
12 | self._http = http
13 | self.identifier = identifier
14 | self.tasks: list[Task] = []
15 | self.id: int = data['id']
16 | self.name: str = data['name']
17 | self.cron: Cron = Cron(**data['cron'])
18 | self.is_active: bool = data['is_active']
19 | self.is_processing: bool = data['is_processing']
20 | self.only_when_online: bool = data['only_when_online']
21 | self.created_at: str = data['created_at']
22 | self.updated_at: str | None = data.get('updated_at')
23 | self.last_run_at: str | None = data.get('last_run_at')
24 | self.next_run_at: str | None = data.get('next_run_at')
25 |
26 | def __repr__(self) -> str:
27 | return f''
29 |
30 | def __str__(self) -> str:
31 | return self.name
32 |
--------------------------------------------------------------------------------
/pytero/servers.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 | from .types import _Http, Allocation, Container, Egg, FeatureLimits, Limits, \
3 | Location, Nest
4 | from .util import transform
5 |
6 | # pylint: disable=C0103
7 |
8 | __all__ = ('AppServer', 'ClientServer')
9 |
10 |
11 | class AppServer:
12 | def __init__(self, http, data: dict[str, Any]) -> None:
13 | self._http = http
14 | self.id: int = data['id']
15 | self.external_id: str | None = data.get('external_id')
16 | self.uuid: str = data['uuid']
17 | self.identifier: str = data['identifier']
18 | self.created_at: str = data['created_at']
19 | self.suspended = False
20 | self._patch(data)
21 | self._patch_relations(data.get('relationships'))
22 |
23 | def __repr__(self) -> str:
24 | return f''
25 |
26 | def __str__(self) -> str:
27 | return self.name
28 |
29 | def _patch(self, data: dict[str, Any]) -> None:
30 | self.name: str = data['name']
31 | self.description: str | None = data.get('description')
32 | self.status: str | None = data.get('status')
33 | self.suspended: bool = data.get('suspended', False)
34 | self.limits: Limits = Limits(**data['limits'])
35 | self.feature_limits: FeatureLimits = \
36 | FeatureLimits(**data['feature_limits'])
37 |
38 | self.user_id: int = data['user']
39 | self.node_id: int = data['node']
40 | self.allocation_id: int = data['allocation']
41 | self.allocations: list[Allocation] | None = None
42 | self.nest_id: int = data['nest']
43 | self.nest: Nest | None = None
44 | self.egg_id: int = data['egg']
45 | self.egg: Egg | None = None
46 | self.container: Container = Container(**data['container'])
47 | self.location: Location | None = None
48 | self.updated_at: str | None = data.get('updated_at')
49 |
50 | def _patch_relations(self, data: dict[str, Any] | None) -> None:
51 | if data is None:
52 | return
53 |
54 | if 'allocations' in data:
55 | self.allocations = []
56 | for datum in data['allocations']['data']:
57 | self.allocations.append(Allocation(**datum['attributes']))
58 |
59 | if 'nest' in data:
60 | self.nest = Nest(**data['nest']['attributes'])
61 |
62 | if 'egg' in data:
63 | self.egg = Egg(**data['egg']['attributes'])
64 |
65 | if 'location' in data:
66 | self.location = Location(**data['location']['attributes'])
67 |
68 | def to_dict(self) -> dict[str, Any]:
69 | return transform(
70 | self,
71 | ignore=['_http'],
72 | maps={
73 | 'user_id': 'user',
74 | 'node_id': 'node',
75 | 'allocation_id': 'allocation',
76 | 'egg_id': 'egg',
77 | 'nest_id': 'nest'})
78 |
79 | async def update_details(
80 | self,
81 | *,
82 | external_id: str = None,
83 | name: str = None,
84 | user: int = None,
85 | description: str = None
86 | ) -> None:
87 | external_id = external_id or self.external_id
88 | name = name or self.name
89 | user = user or self.user_id
90 | description = description or self.description
91 |
92 | data: AppServer = await self._http.update_server_details(
93 | self.id,
94 | external_id=external_id,
95 | name=name,
96 | user=user,
97 | description=description)
98 |
99 | self._patch(data.to_dict())
100 |
101 | async def update_build(
102 | self,
103 | *,
104 | allocation: int = None,
105 | oom_disabled: bool = True,
106 | limits: Limits = None,
107 | feature_limits: FeatureLimits = None,
108 | add_allocations: list[int] = None,
109 | remove_allocations: list[int] = None
110 | ) -> None:
111 | body = {
112 | 'allocation': allocation or self.allocation_id,
113 | 'limits': limits or self.limits,
114 | 'feature_limits': feature_limits or self.feature_limits}
115 |
116 | if oom_disabled is not None:
117 | body['oom_disabled'] = oom_disabled
118 |
119 | if add_allocations is not None:
120 | body['add_allocations'] = add_allocations
121 |
122 | if remove_allocations is not None:
123 | body['remove_allocations'] = remove_allocations
124 |
125 | data: AppServer = await self._http.update_server_build(self.id, **body)
126 | self._patch(data.to_dict())
127 |
128 | async def update_startup(
129 | self,
130 | *,
131 | startup: str = None,
132 | environment: dict[str, int | str | bool] = None,
133 | egg: int = None,
134 | image: str = None,
135 | skip_scripts: bool = False
136 | ) -> None:
137 | data: AppServer = await self._http.update_server_startup(
138 | self.id,
139 | startup=startup or self.container.startup_command,
140 | environment=environment or self.container.environment,
141 | egg=egg or self.egg_id,
142 | image=image or self.container.image,
143 | skip_scripts=skip_scripts)
144 |
145 | self._patch(data.to_dict())
146 |
147 | async def suspend(self) -> None:
148 | await self._http.suspend_server(self.id)
149 | self.suspended = True
150 |
151 | async def unsuspend(self) -> None:
152 | await self._http.unsuspend_server(self.id)
153 | self.suspended = False
154 |
155 | async def reinstall(self) -> None:
156 | await self._http.reinstall_server(self.id)
157 |
158 |
159 | class ClientServer:
160 | def __init__(self, http: _Http, data: dict[str, Any]) -> None:
161 | self._http = http
162 | self.uuid: str = data['uuid']
163 | self.identifier: str = data['identifier']
164 | self.internal_id: int = data['internal_id']
165 | self._patch(data)
166 |
167 | def __repr__(self) -> str:
168 | return f''
169 |
170 | def __str__(self) -> str:
171 | return self.name
172 |
173 | def _patch(self, data: dict[str, Any]) -> None:
174 | self.server_owner: bool = data['server_owner']
175 | self.name: str = data['name']
176 | self.node: str = data['node']
177 | self.description: str | None = data.get('description')
178 | self.sftp_details: dict[str, Any] = data['sftp_details']
179 | self.limits: Limits = Limits(**data['limits'])
180 | self.feature_limits: FeatureLimits = FeatureLimits(
181 | **data['feature_limits'])
182 | self.invocation: str = data['invocation']
183 | self.docker_image: str = data['docker_image']
184 | self.egg_features: list[str] | None = data.get('egg_features')
185 | self.status: str | None = data.get('status')
186 | self.is_suspended: bool = data['is_suspended']
187 | self.is_installing: bool = data['is_installing']
188 | self.is_transferring: bool = data['is_transferring']
189 |
190 | def _patch_relations(self) -> None:
191 | pass
192 |
193 | def to_dict(self) -> dict[str, Any]:
194 | return transform(self, ignore=['_http'])
195 |
--------------------------------------------------------------------------------
/pytero/shard.py:
--------------------------------------------------------------------------------
1 | from json import loads
2 | from time import time
3 | from typing import Any, Callable, Coroutine, overload
4 | from aiohttp import ClientSession, ClientWebSocketResponse, WSMessage
5 | from .errors import ShardError
6 | from .events import Emitter
7 | from .types import _Http, WebSocketEvent
8 |
9 |
10 | __all__ = ('Shard',)
11 |
12 |
13 | class Shard(Emitter):
14 | def __init__(self, http: _Http, identifier: str) -> None:
15 | super().__init__()
16 | self._http = http
17 | self._conn: ClientWebSocketResponse = None
18 | self.origin: str = http.url
19 | self.identifier: str = identifier
20 | self.ping: float = float('nan')
21 | self.last_ping: float = float('nan')
22 |
23 | def __repr__(self) -> str:
24 | return f''
25 |
26 | @property
27 | def closed(self) -> bool:
28 | return self._conn is None
29 |
30 | @overload
31 | def event(self,
32 | func: Coroutine[Any, Any, Callable[[str], None]]
33 | ) -> Coroutine[Any, Any, Callable[[str], None]]:
34 | ...
35 |
36 | def event(self, func: Callable[[str], None]) -> Callable[[str], None]:
37 | super().add_event(func.__name__, func)
38 | return func
39 |
40 | async def _debug(self, msg: str, /) -> None:
41 | await super().emit_event('on_debug', f'debug {self.identifier}: {msg}')
42 |
43 | def _evt(self, name: str, args: list[str] = None) -> dict[str, list[str]]:
44 | if args is None:
45 | args = []
46 |
47 | return {'event': name, 'args': args}
48 |
49 | async def _heartbeat(self) -> None:
50 | if self.closed:
51 | raise ShardError('connection not available for this shard')
52 |
53 | auth: dict[str, Any] = await self._http.get(
54 | f'/servers/{self.identifier}/websocket')
55 | await self._conn.send_json(self._evt('auth', auth['data']['token']))
56 |
57 | async def launch(self) -> None:
58 | if not self.closed:
59 | return
60 |
61 | await self._debug(f'connecting to {self.identifier}')
62 | auth: dict[str, Any] = await self._http.get(
63 | '/servers/%s/websocket' % self.identifier)
64 | await self._debug('attempting to connect to websocket')
65 |
66 | async with ClientSession() as session:
67 | async with session.ws_connect(auth['data']['socket'],
68 | origin=self.origin) as self._conn:
69 | await self._debug('authenticating connection')
70 | await self._conn.send_json(self._evt('auth',
71 | auth['data']['token']))
72 | await self._debug('authentication sent')
73 |
74 | async for msg in self._conn:
75 | await self._on_event(msg)
76 |
77 | def destroy(self) -> None:
78 | if not self.closed:
79 | self._conn.close()
80 | self._conn = None
81 |
82 | async def _on_event(self, event: WSMessage, /) -> None:
83 | json = event.json()
84 | await super().emit_event('on_raw', json)
85 | data = WebSocketEvent(**json)
86 | await self._debug(f'received event: {data.event}')
87 |
88 | match data.event:
89 | case 'auth success':
90 | self.ping = time() - self.last_ping
91 | self.last_ping = time()
92 | await super().emit_event('on_auth_success')
93 | case 'token expiring':
94 | await self._heartbeat()
95 | case 'token expired':
96 | self.destroy()
97 | await self.launch()
98 | case 'daemon error' | 'jwt error':
99 | if super().has_event('on_error'):
100 | await super().emit_event('on_error', ''.join(data.args))
101 | else:
102 | self.destroy()
103 | raise ShardError(''.join(data.args))
104 | case 'status':
105 | await super().emit_event('on_status_update', data.args[0])
106 | case 'stats':
107 | p = loads(''.join(data.args))
108 | await super().emit_event('on_stats_update', p)
109 | case 'console output':
110 | await super().emit_event('on_output', ''.join(data.args))
111 | case 'daemon message':
112 | await super().emit_event('on_daemon_log', ''.join(data.args))
113 | case 'install start':
114 | await super().emit_event('on_install_start')
115 | case 'install output':
116 | await super().emit_event('on_install_log', ''.join(data.args))
117 | case 'install completed':
118 | await super().emit_event('on_install_end')
119 | case 'transfer logs':
120 | await super().emit_event('on_transfer_log', ''.join(data.args))
121 | case 'transfer status':
122 | await super().emit_event('on_transfer_status',
123 | ''.join(data.args))
124 | case 'backup completed':
125 | p = None
126 | if len(data.args) > 0:
127 | p = loads(''.join(data.args))
128 |
129 | await super().emit_event('on_backup_complete', p)
130 | case _:
131 | await super().emit_event('on_error',
132 | f"received unknown event \
133 | '{data.event}'")
134 |
135 | def request_logs(self) -> None:
136 | if not self.closed:
137 | self._conn.send_json(self._evt('send command'))
138 |
139 | def request_stats(self) -> None:
140 | if not self.closed:
141 | self._conn.send_json(self._evt('send stats'))
142 |
143 | def send_command(self, cmd: str, /) -> None:
144 | if not self.closed:
145 | self._conn.send_json(self._evt('send command', cmd))
146 |
147 | def send_state(self, state: str, /) -> None:
148 | if not self.closed:
149 | self._conn.send_json(self._evt('set state', state))
150 |
--------------------------------------------------------------------------------
/pytero/types.py:
--------------------------------------------------------------------------------
1 | """General data, enum and typing classes for Pytero."""
2 |
3 | from dataclasses import dataclass
4 | from typing import Any, Callable, Optional
5 |
6 | # pylint: disable=C0103
7 |
8 | __all__ = (
9 | '_Http',
10 | 'Activity',
11 | 'Allocation',
12 | 'APIKey',
13 | 'AppDatabase',
14 | 'ClientDatabase',
15 | 'ClientHost',
16 | 'ClientVariable',
17 | 'Container',
18 | 'Cron',
19 | 'DeployNodeOptions',
20 | 'DeployServerOptions',
21 | 'EggScript',
22 | 'EggConfiguration',
23 | 'Egg',
24 | 'FeatureLimits',
25 | 'Limits',
26 | 'Location',
27 | 'Nest',
28 | 'NetworkAllocation',
29 | 'NodeConfiguration',
30 | 'Resources',
31 | 'SSHKey',
32 | 'Statistics',
33 | 'Task',
34 | 'WebSocketAuth',
35 | 'WebSocketEvent',
36 | 'Backup'
37 | )
38 |
39 |
40 | class _Http:
41 | url: str
42 | key: str
43 | _raw: Callable[[str, str, Any | None], Any]
44 | get: Callable[[str], Any]
45 | post: Callable[[str], Any]
46 | patch: Callable[[str], Any]
47 | put: Callable[[str], Any]
48 | delete: Callable[[str], Any]
49 |
50 |
51 | @dataclass
52 | class Activity:
53 | id: str
54 | batch: str
55 | event: str
56 | is_api: bool
57 | ip: Optional[str]
58 | description: Optional[str]
59 | properties: dict[str, Any]
60 | has_additional_metadata: bool
61 | timestamp: str
62 |
63 | def __repr__(self) -> str:
64 | return f''
65 |
66 | def to_dict(self) -> dict[str, Any]:
67 | return self.__dict__
68 |
69 |
70 | @dataclass
71 | class Allocation:
72 | id: int
73 | ip: str
74 | alias: str | None
75 | port: int
76 | notes: str | None
77 | assigned: bool
78 |
79 | def __repr__(self) -> str:
80 | return f''
81 |
82 | def to_dict(self) -> dict[str, Any]:
83 | d = self.__dict__
84 | del d['id']
85 | return d
86 |
87 |
88 | @dataclass
89 | class APIKey:
90 | identifier: str
91 | description: str
92 | allowed_ips: list[str]
93 | created_at: str
94 | last_used_at: Optional[str]
95 |
96 | def __repr__(self) -> str:
97 | return f''
98 |
99 |
100 | @dataclass
101 | class AppDatabase:
102 | id: int
103 | server: int
104 | host: int
105 | database: str
106 | username: str
107 | remote: str
108 | max_connections: str
109 | created_at: str
110 | updated_at: Optional[str]
111 | password: str | None = None
112 |
113 |
114 | @dataclass
115 | class ClientHost:
116 | address: str
117 | port: int
118 |
119 | def to_dict(self) -> dict[str, Any]:
120 | return self.__dict__
121 |
122 |
123 | @dataclass
124 | class ClientDatabase:
125 | id: str
126 | name: str
127 | username: str
128 | host: ClientHost
129 | connections_from: str
130 | max_connections: int
131 |
132 | def to_dict(self) -> dict[str, Any]:
133 | d = self.__dict__
134 | del d['id']
135 | return d
136 |
137 |
138 | @dataclass
139 | class ClientVariable:
140 | name: str
141 | description: str
142 | env_variable: str
143 | default_value: str | None
144 | server_value: str | None
145 | is_editable: bool
146 | rules: str
147 |
148 |
149 | @dataclass
150 | class Container:
151 | startup_command: str
152 | environment: dict[str, int | str | bool]
153 | image: str
154 | installed: bool
155 |
156 | def to_dict(self) -> dict[str, Any]:
157 | return self.__dict__
158 |
159 |
160 | @dataclass
161 | class Cron:
162 | day_of_week: str
163 | day_of_month: str
164 | month: str
165 | hour: str
166 | minute: str
167 |
168 | def to_dict(self) -> dict[str, Any]:
169 | return self.__dict__
170 |
171 |
172 | @dataclass
173 | class DeployServerOptions:
174 | locations: list[int]
175 | dedicated_ip: bool
176 | port_range: list[str]
177 |
178 | def to_dict(self) -> dict[str, Any]:
179 | return self.__dict__
180 |
181 |
182 | @dataclass
183 | class DeployNodeOptions:
184 | memory: int
185 | disk: int
186 | location_ids: list[int]
187 |
188 | def to_dict(self) -> dict[str, Any]:
189 | return self.__dict__
190 |
191 |
192 | @dataclass
193 | class EggConfiguration:
194 | files: list[str]
195 | startup: dict[str, str]
196 | stop: str
197 | logs: list[str]
198 | file_denylist: list[str]
199 | extends: Optional[str]
200 |
201 | def to_dict(self) -> dict[str, Any]:
202 | return self.__dict__
203 |
204 |
205 | @dataclass
206 | class EggScript:
207 | privileged: bool
208 | install: str
209 | entry: str
210 | container: str
211 | extends: Optional[str]
212 |
213 | def to_dict(self) -> dict[str, Any]:
214 | return self.__dict__
215 |
216 |
217 | @dataclass
218 | class Egg:
219 | id: int
220 | uuid: str
221 | name: str
222 | author: str
223 | description: str
224 | nest: int
225 | # technically deprecated
226 | docker_image: str
227 | docker_images: dict[str, str]
228 | config: EggConfiguration
229 | startup: str
230 | script: EggScript
231 | created_at: str
232 | updated_at: Optional[str]
233 |
234 | def __repr__(self) -> str:
235 | return f''
236 |
237 | def to_dict(self) -> dict[str, Any]:
238 | d = self.__dict__
239 | del d['id'], d['uuid'], d['created_at'], d['updated_at']
240 | return d
241 |
242 |
243 | @dataclass
244 | class FeatureLimits:
245 | allocations: int
246 | backups: int
247 | databases: int
248 |
249 | def to_dict(self) -> dict[str, Any]:
250 | return self.__dict__
251 |
252 |
253 | @dataclass
254 | class Limits:
255 | memory: int
256 | disk: int
257 | swap: int
258 | io: int
259 | cpu: int
260 | threads: Optional[str]
261 | oom_disabled: Optional[bool]
262 |
263 | def to_dict(self) -> dict[str, Any]:
264 | return self.__dict__
265 |
266 |
267 | @dataclass
268 | class Nest:
269 | id: int
270 | uuid: str
271 | author: str
272 | name: str
273 | description: str
274 | created_at: str
275 | updated_at: str | None
276 |
277 | def __repr__(self) -> str:
278 | return f''
279 |
280 | def to_dict(self) -> dict[str, Any]:
281 | return {
282 | 'author': self.author,
283 | 'name': self.name,
284 | 'description': self.description}
285 |
286 |
287 | @dataclass
288 | class NetworkAllocation:
289 | id: int
290 | ip: str
291 | ip_alias: str | None
292 | port: int
293 | notes: str | None
294 | is_default: bool
295 |
296 | def __repr__(self) -> str:
297 | return f''
299 |
300 |
301 | @dataclass
302 | class NodeConfiguration:
303 | debug: bool
304 | uuid: str
305 | token_id: str
306 | token: str
307 | api: dict[str, int | str | dict[str, str | bool]]
308 | system: dict[str, str | dict[str, int]]
309 | allowed_mounts: list[str]
310 | remote: str
311 |
312 | def __repr__(self) -> str:
313 | return f''
314 |
315 |
316 | @dataclass
317 | class Location:
318 | id: int
319 | long: str
320 | short: str
321 | created_at: str
322 | updated_at: str | None
323 |
324 | def __repr__(self) -> str:
325 | return f''
326 |
327 |
328 | @dataclass
329 | class Resources:
330 | memory_bytes: int
331 | cpu_absolute: int
332 | disk_bytes: int
333 | network_rx_bytes: int
334 | network_tx_bytes: int
335 | uptime: int
336 |
337 | def __repr__(self) -> str:
338 | return f''
340 |
341 |
342 | @dataclass
343 | class SSHKey:
344 | name: str
345 | fingerprint: str
346 | public_key: str
347 | created_at: str
348 |
349 |
350 | @dataclass
351 | class Statistics:
352 | current_state: str
353 | is_suspended: bool
354 | resources: Resources
355 |
356 | def __repr__(self) -> str:
357 | return f''
359 |
360 |
361 | @dataclass
362 | class Task:
363 | id: int
364 | sequence_id: int
365 | action: str
366 | payload: str
367 | time_offset: int
368 | is_queued: bool
369 | continue_on_failure: bool
370 | created_at: str
371 | updated_at: str | None
372 |
373 |
374 | @dataclass
375 | class WebSocketAuth:
376 | socket: str
377 | token: str
378 |
379 |
380 | @dataclass
381 | class WebSocketEvent:
382 | event: str
383 | args: list[str] | None
384 |
385 |
386 | @dataclass
387 | class Backup:
388 | uuid: str
389 | is_successful: bool
390 | is_locked: bool
391 | name: str
392 | ignored_files: list[str]
393 | checksum: str | None
394 | bytes: int
395 | created_at: str
396 | completed_at: str | None
397 |
398 | def __repr__(self) -> str:
399 | return f''
400 |
--------------------------------------------------------------------------------
/pytero/users.py:
--------------------------------------------------------------------------------
1 | """Account and user definitions for user objects in the Pterodactyl API."""
2 |
3 | from typing import Any, Optional
4 | from .permissions import Permissions
5 | from .servers import AppServer
6 | from .types import _Http, APIKey, Activity, SSHKey
7 | from .util import transform
8 |
9 | # pylint: disable=R0902
10 |
11 | __all__ = ('Account', 'SubUser', 'User')
12 |
13 |
14 | class Account:
15 | """Represents an account object in the client API."""
16 |
17 | def __init__(self, http, data: dict[str, Any]) -> None:
18 | self._http = http
19 | self._id = data['id']
20 | self.email = None
21 | self._patch(data)
22 |
23 | def __repr__(self) -> str:
24 | return f''
25 |
26 | def __str__(self) -> str:
27 | return self.first_name + ' ' + self.last_name
28 |
29 | def _patch(self, data: dict[str, Any]) -> None:
30 | self.username: str = data['username']
31 | self.email: str = data['email']
32 | self.first_name: str = data['first_name']
33 | self.last_name: str = data['last_name']
34 | self.language: str = data['language']
35 | self.admin: bool = data['admin']
36 |
37 | def to_dict(self) -> dict[str, Any]:
38 | return transform(self, ignore=['_http'])
39 |
40 | def get_two_factor(self) -> str:
41 | return self._http.get_account_two_factor()
42 |
43 | def enable_two_factor(self, code: int, /) -> list[str]:
44 | return self._http.enable_account_two_factor(code)
45 |
46 | def disable_two_factor(self, password: str, /) -> None:
47 | return self._http.disable_account_two_factor(password)
48 |
49 | async def update_email(self, email: str, password: str) -> None:
50 | await self._http.update_account_email(email, password)
51 | self.email = email
52 |
53 | def update_password(self, old: str, new: str) -> None:
54 | return self._http.update_account_password(old, new)
55 |
56 | def get_activities(self) -> list[Activity]:
57 | return self._http.get_account_activities()
58 |
59 | def get_api_keys(self) -> list[APIKey]:
60 | return self._http.get_api_keys()
61 |
62 | def get_ssh_keys(self) -> list[SSHKey]:
63 | return self._http.get_ssh_keys()
64 |
65 |
66 | class SubUser:
67 | def __init__(self, http: _Http, data: dict[str, Any]) -> None:
68 | self._http = http
69 | self.uuid: str = data['uuid']
70 | self.username: str = data['username']
71 | self.email: str = data['email']
72 | self.image: str | None = data.get('image')
73 | self.permissions = Permissions(*data['permissions'])
74 | self.two_factor_enabled: bool = data['2fa_enabled']
75 | self.created_at: str = data['created_at']
76 |
77 | def __repr__(self) -> str:
78 | return f''
79 |
80 | def __str__(self) -> str:
81 | return self.username
82 |
83 | def to_dict(self) -> dict[str, Any]:
84 | return transform(
85 | self,
86 | ignore=['_http'],
87 | maps={'two_factor_enabled': '2fa_enabled'})
88 |
89 |
90 | class User:
91 | def __init__(self, http, data: dict[str, Any]) -> None:
92 | self._http = http
93 | self._id: int = data['id']
94 | self.uuid: str = data['uuid']
95 | self.created_at: str = data['created_at']
96 | self._patch(data)
97 | self._patch_relations(data.get('relationships'))
98 |
99 | def __repr__(self) -> str:
100 | return f''
101 |
102 | def __str__(self) -> str:
103 | return self.first_name + ' ' + self.last_name
104 |
105 | def _patch(self, data: dict[str, Any]) -> None:
106 | self.external_id: Optional[str] = data.get('external_id')
107 | self.username: str = data['username']
108 | self.email: str = data['email']
109 | self.first_name: str = data['first_name']
110 | self.last_name: str = data['last_name']
111 | self.language: str = data['language']
112 | self.root_admin: bool = data['root_admin']
113 | self.two_factor: bool = data['2fa']
114 | self.updated_at: Optional[str] = data.get('updated_at')
115 | self.servers: list[AppServer] = []
116 |
117 | def _patch_relations(self, data: dict[str, Any] | None) -> None:
118 | if data is None:
119 | return
120 |
121 | if 'servers' in data:
122 | for datum in data['servers']['data']:
123 | self.servers.append(AppServer(self._http, datum['attributes']))
124 |
125 | def to_dict(self) -> dict[str, Any]:
126 | return transform(self, ignore=['_http'], maps={'two_factor': '2fa'})
127 |
128 | async def update(
129 | self,
130 | *,
131 | email: str = None,
132 | username: str = None,
133 | first_name: str = None,
134 | last_name: str = None,
135 | password: str = None,
136 | external_id: str = None,
137 | root_admin: bool = None
138 | ) -> None:
139 | email = email or self.email
140 | username = username or self.username
141 | first_name = first_name or self.first_name
142 | last_name = last_name or self.last_name
143 | external_id = external_id or self.external_id
144 | if root_admin is None:
145 | root_admin = self.root_admin
146 |
147 | data: User = await self._http.update_user(
148 | self._id,
149 | email=email,
150 | username=username,
151 | first_name=first_name,
152 | last_name=last_name,
153 | password=password,
154 | external_id=external_id,
155 | root_admin=root_admin)
156 |
157 | self._patch(data.to_dict())
158 |
--------------------------------------------------------------------------------
/pytero/util.py:
--------------------------------------------------------------------------------
1 | """Utility functions for Pytero."""
2 |
3 | from typing import Any, Callable
4 |
5 |
6 | __all__ = ('transform',)
7 |
8 |
9 | def transform(
10 | data: object,
11 | *,
12 | ignore: list[str] = None,
13 | cast: dict[str, Callable[..., Any]] = None,
14 | maps: dict[str, str] = None
15 | ) -> dict[str, Any]:
16 | """Transforms an object into its JSON object form."""
17 | res = {}
18 | if ignore is None:
19 | ignore = []
20 |
21 | if cast is None:
22 | cast = {}
23 |
24 | if maps is None:
25 | maps = {}
26 |
27 | for key, value in data.__dict__.items():
28 | if key in ignore:
29 | continue
30 |
31 | if maps.get(key):
32 | key = maps[key]
33 |
34 | if key in cast:
35 | try:
36 | res[key] = cast[key](value)
37 | except TypeError:
38 | res[key] = str(value)
39 | else:
40 | if hasattr(value, 'to_dict'):
41 | res[key] = value.to_dict()
42 | else:
43 | res[key] = value
44 |
45 | return res
46 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | """# Pytero
2 | A flexible API wrapper for the Pterodactyl API
3 |
4 | Author: Devonte W
5 | Repository: https://github.com/PteroPackages/Pytero
6 | License: MIT
7 |
8 | © 2021-present PteroPackages
9 | """
10 |
11 | from setuptools import setup
12 | from pytero import __version__
13 |
14 |
15 | with open('./README.md', encoding='utf-8') as file:
16 | LONG_DESC = '\n'.join(file.readlines())
17 | file.close()
18 |
19 |
20 | setup(
21 | name='pytero',
22 | author='Devonte W',
23 | url='https://github.com/PteroPackages/Pytero',
24 | license='MIT',
25 | version=__version__,
26 | packages=['pytero'],
27 | description='A flexible API wrapper for Pterodactyl in Python',
28 | long_description=LONG_DESC,
29 | long_desription_content_type='text/markdown',
30 | include_package_data=True,
31 | install_requires=['aiohttp'],
32 | python_requires='>=3.10.0',
33 | classifiers=[
34 | 'Development Status :: 3 - Alpha',
35 | 'License :: OSI Approved :: MIT License',
36 | 'Intended Audience :: Developers',
37 | 'Operating System :: OS Independent',
38 | 'Programming Language :: Python :: 3.10'
39 | ]
40 | )
41 |
--------------------------------------------------------------------------------