├── .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 | --------------------------------------------------------------------------------