From 4892430f19959b1502723e27d822a74423763c27 Mon Sep 17 00:00:00 2001 From: DarkCat09 Date: Fri, 26 Aug 2022 16:14:07 +0400 Subject: [PATCH] MkDocs, Readme, Files API, Automated session saving, v2.0.1 MkDocs: sphinx docstrings rewritten to google, improved config, written the major part of how-to. Readme: centered title + logo, added badges, features list, updated changelog. Improved Files API, added automatical session saving and restoring to Client. Some changes in makefile and gitignore. License Notice now refers to all contributors. --- .gitignore | 3 + Makefile | 7 +- NOTICE | 2 +- README.md | 100 ++++++++++--- docs/howto.md | 1 - docs/howto/auth.md | 165 +++++++++++++++++++++ docs/howto/config.md | 1 + docs/howto/discord.md | 1 + docs/howto/files.md | 233 +++++++++++++++++++++++++++++ docs/howto/players.md | 57 ++++++++ docs/howto/server.md | 219 +++++++++++++++++++++++++++ docs/howto/websocket.md | 1 + docs/index.md | 153 ++++++++++++++++++- docs/reference.md | 39 ----- docs/reference/atclient.md | 2 + docs/reference/atconf.md | 2 + docs/reference/atconnect.md | 2 + docs/reference/aterrors.md | 2 + docs/reference/atfile.md | 2 + docs/reference/atfm.md | 2 + docs/reference/atjsparse.md | 2 + docs/reference/atplayers.md | 2 + docs/reference/atserver.md | 2 + docs/reference/atwss.md | 2 + examples/files_example.py | 7 +- mkdocs.yml | 36 ++++- python_aternos/atclient.py | 222 +++++++++++++++++++--------- python_aternos/atconf.py | 106 +++++++------- python_aternos/atconnect.py | 136 +++++++++-------- python_aternos/aterrors.py | 36 +++-- python_aternos/atfile.py | 285 +++++++++++++++++++++++++++--------- python_aternos/atfm.py | 124 +++++++++------- python_aternos/atjsparse.py | 35 ++--- python_aternos/atplayers.py | 46 +++--- python_aternos/atserver.py | 281 ++++++++++++++++++++--------------- python_aternos/atwss.py | 68 +++++---- setup.py | 2 +- tests/test_js.py | 4 + tests/test_login.py | 11 +- 39 files changed, 1832 insertions(+), 569 deletions(-) delete mode 100644 docs/howto.md create mode 100644 docs/howto/auth.md create mode 100644 docs/howto/config.md create mode 100644 docs/howto/discord.md create mode 100644 docs/howto/files.md create mode 100644 docs/howto/players.md create mode 100644 docs/howto/server.md create mode 100644 docs/howto/websocket.md delete mode 100644 docs/reference.md create mode 100644 docs/reference/atclient.md create mode 100644 docs/reference/atconf.md create mode 100644 docs/reference/atconnect.md create mode 100644 docs/reference/aterrors.md create mode 100644 docs/reference/atfile.md create mode 100644 docs/reference/atfm.md create mode 100644 docs/reference/atjsparse.md create mode 100644 docs/reference/atplayers.md create mode 100644 docs/reference/atserver.md create mode 100644 docs/reference/atwss.md diff --git a/.gitignore b/.gitignore index 63fe49a..2fce8e2 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,9 @@ instance/ # Sphinx documentation docs/_build/ +# MkDocs +site/ + # PyBuilder .pybuilder/ target/ diff --git a/Makefile b/Makefile index d30d79e..6c126e1 100644 --- a/Makefile +++ b/Makefile @@ -5,8 +5,11 @@ upload: python -m twine upload dist/* clean: - rm -rf dist/ python_aternos.egg-info/ - rm -rf .mypy_cache/ python_aternos/__pycache__/ + rm -rf dist python_aternos.egg-info + rm -rf python_aternos/__pycache__ + rm -rf examples/__pycache__ + rm -rf tests/__pycache__ + rm -rf site .mypy_cache check: chmod +x test.sh diff --git a/NOTICE b/NOTICE index 280971f..6269f2a 100644 --- a/NOTICE +++ b/NOTICE @@ -1,4 +1,4 @@ -Copyright 2021-2022 Chechkenev Andrey, lusm554, ghrlt, NotNalin +Copyright 2021-2022 All contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index ce31209..cad3b6b 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,77 @@ -![Python-Aternos Logo](https://i.ibb.co/3RXcXJ1/aternos-400.png) -*** -# Python Aternos +
+ Python Aternos Logo +

+ Python Aternos +
+ + + + + + + + + + + + +
+

+
+ An unofficial Aternos API written in Python. It uses [aternos](https://aternos.org/)' private API and html parsing. -## Installing +Python Aternos supports: + + - Logging in to account with password (plain or hashed) or `ATERNOS_SESSION` cookie value. + - Saving session to the file and restoring. + - Changing username, email and password. + - Parsing Minecraft servers list. + - Parsing server info by its ID. + - Starting/stoping server, restarting, confirming/cancelling launch. + - Updating server info in real-time (view WebSocket API). + - Changing server subdomain and MOTD (message-of-the-day). + - Managing files, settings, players (whitelist, operators, etc.) + +> **Warning** +> +> According to the Aternos' [Terms of Service §5.2e](https://aternos.gmbh/en/aternos/terms#:~:text=Automatically%20accessing%20our%20website%20or%20automating%20actions%20on%20our%20website.), +> you must not use any software or APIs for automated access, +> beacuse they don't receive money from advertisting in this case. +> +> I always try to hide automated python-aternos requests +> using browser-specific headers/cookies, +> but you should make backups to restore your world +> if your account will be banned +> (view [#16](https://github.com/DarkCat09/python-aternos/issues/16) +> and [#46](https://github.com/DarkCat09/python-aternos/issues/46)). + +## Install + +### Common ```bash -pip install python-aternos +$ pip install python-aternos +``` +> **Note** for Windows users +> +> Install `lxml` package from [here](https://www.lfd.uci.edu/~gohlke/pythonlibs/#lxml) +> if you have problems with it, and then execute +> `pip install --no-deps python-aternos` + +### Development +```bash +$ git clone https://github.com/DarkCat09/python-aternos.git +$ cd python-aternos +$ pip install -e . ``` -> Note for Windows users: -Install `lxml` package from [here](https://www.lfd.uci.edu/~gohlke/pythonlibs/#lxml) if you have a problem with it, -and then execute `pip install --no-deps python-aternos` ## Usage To use Aternos API in your Python script, import it -and login with your username and password/MD5. +and login with your username and password or MD5. Then request the servers list using `list_servers()`. -You can start/stop your Aternos server now, calling `start()` or `stop()`. +You can start/stop your Aternos server, calling `start()` or `stop()`. Here is an example how to use the API: ```python @@ -47,36 +101,43 @@ testserv = None for serv in servs: if serv.address == 'test.aternos.org': testserv = serv -if testserv != None: + +if testserv is not None: # Prints a server softaware and its version # (for example, "Vanilla 1.12.2") print(testserv.software, testserv.version) # Starts server testserv.start() ``` -The documentation have not made yet. View examples and ask in the issues. -## [More examples](https://codeberg.org/DarkCat09/python-aternos/src/branch/main/examples) / [on GitHub](https://github.com/DarkCat09/python-aternos/tree/main/examples) +## [More examples](https://github.com/DarkCat09/python-aternos/tree/main/examples) + +## [Documentation](https://darkcat09.codeberg.page/aternos-docs/) + +## [How-To Guide](https://darkcat09.codeberg.page/aternos-docs/howto/auth) ## Changelog -|Version|Description| +|Version|Description | |:-----:|:-----------| |v0.1|The first release.| |v0.2|Fixed import problem.| |v0.3|Implemented files API, added typization.| |v0.4|Implemented configuration API, some bugfixes.| |v0.5|The API was updated corresponding to new Aternos security methods. Huge thanks to [lusm554](https://github.com/lusm554).| -|v0.6/v1.0.0|Code refactoring, websockets API and session saving to prevent detecting automation access.| +|**v0.6/v1.0.0**|Code refactoring, websockets API and session saving to prevent detecting automation access.| |v1.0.x|Lots of bugfixes, changed versioning (SemVer).| |v1.1.x|Documentation, unit tests, pylint, bugfixes, changes in atwss.| -|v1.2.x|Solution for #25| -|v1.3.x|Full implementation of config and software API.| -|v1.4.x|Shared access API and Google Drive backups.| +|**v1.1.2/v2.0.0**|Solution for [#25](https://github.com/DarkCat09/python-aternos/issues/25) (Cloudflare bypassing), bugfixes in JS parser.| +|v2.0.x|Documentation, automatically saving/restoring session, improvements in Files API.| +|v2.1.x|Fixes in the implementation of websockets API.| +|**v2.2.x**|Using Node.js as a JS interpreter if it's installed.| +|v3.0.x|Full implementation of config and software API.| +|v3.1.x|Shared access API and Google Drive backups.| ## License [License Notice](NOTICE): ``` -Copyright 2021-2022 Chechkenev Andrey, lusm554, ghrlt, NotNalin +Copyright 2021-2022 All contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -90,4 +151,3 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` -You **don't** need to attribute me, if you are just using this module installed from PIP or wheel. diff --git a/docs/howto.md b/docs/howto.md deleted file mode 100644 index 62db1fc..0000000 --- a/docs/howto.md +++ /dev/null @@ -1 +0,0 @@ -Very interesting information diff --git a/docs/howto/auth.md b/docs/howto/auth.md new file mode 100644 index 0000000..62c52eb --- /dev/null +++ b/docs/howto/auth.md @@ -0,0 +1,165 @@ +# How-To 1: Logging in + +## Intro +Firstly, let's install the library using the command from ["Common install" section](../#common). +```bash +pip install python-aternos +``` + +Also, [register](https://aternos.org/go/) an Aternos account if you haven't one. +Now you are ready. + +## Authorization with password +Import python-aternos module: +```python +from python_aternos import Client +``` + +Then, you can log in to your account using from_credentials method +specifying your username and password. +```python +at = Client.from_credentials('username', 'password') +``` +This line will create Client object and save it to `at` variable. + +Okay, we are logged in. What's next? + +## Servers list +Request the list of your servers: +```python +servers = at.list_servers() +``` + +This variable must contain something like: +```python +[] +``` + +If you have only one server in your account, +get it by the zero index: +```python +serv = servers[0] +``` + +Otherwise, iterate over the list to find it by IP or subdomain: + +```python +# 1st way: For-loop +# Our server: test.aternos.me + +# Find by IP (domain) +serv = None +for s in servers: + if s.domain == 'test.aternos.me': + serv = s + +# Or find by subdomain +# (part before .aternos.me) +serv = None +for s in servers: + if s.subdomain == 'test': + serv = s + +# Important check +if serv is None: + print('Not found!') + exit() +``` + +```python +# 2nd way: Dict comprehension + +serv = { + 'serv': s + for s in servers + if s.subdomain == 'test' +}.get('serv', None) + +if serv is None: + print('Not found!') + exit() +``` + +`serv` is an AternosServer object. I'll explain it more detailed in the next part. +Now, let's just try to start and stop server: +```python +# Start +serv.start() + +# Stop +serv.stop() +``` + +## Saving session +In the version `v2.0.1` and above, +python-aternos automatically saves and restores session cookie, +so you don't need to do it by yourself now. + +Before, you should save session manually: +```python +# This code is useless in new versions, +# because they do it automatically. + +from python_aternos import Client + +at = Client.from_credentails('username', 'password') +myserv = at.list_servers()[0] + +... + +at.save_session() + +# Closing python interpreter +# and opening it again + +from python_aternos import Client + +at = Client.restore_session() +myserv = at.list_servers()[0] + +... +``` +Function `save_session()` writes session cookie and cached servers list to `.aternos` file in your home directory. +`restore_session()` creates Client object from session cookie and restores servers list. +This feature reduces the count of network requests and allows you to log in and request servers much faster. + +If you created a new server, but it doesn't appear in `list_servers` result, call it with `cache=False` argument. +```python +# Refreshing list +servers = at.list_servers(cache=False) +``` + +## Username, email, password +Change them using the corresponding methods: +```python +at.change_username('new1cool2username3') +at.change_password('old_password', 'new_password') +at.change_email('new@email.com') +``` + +## Hashing passwords +For security reasons, Aternos API takes MD5 hashed passwords, not plain. + +`from_credentials` hashes your credentials and passes to `from_hashed` classmethod. +`change_password` also hashes passwords and calls `change_password_hashed`. +And you can use these methods too. +Python-Aternos contains a handy function `Client.md5encode` that can help you with it. + +```python +>>> from python_aternos import Client +>>> Client.md5encode('old_password') +'0512f08120c4fef707bd5e2259c537d0' +>>> Client.md5encode('new_password') +'88162595c58939c4ae0b35f39892e6e7' +``` + +```python +from python_aternos import Client + +my_passwd = '0512f08120c4fef707bd5e2259c537d0' +new_passwd = '88162595c58939c4ae0b35f39892e6e7' + +at = Client.from_hashed('username', my_passwd) + +at.change_password_hashed(my_passwd, new_passwd) +``` diff --git a/docs/howto/config.md b/docs/howto/config.md new file mode 100644 index 0000000..dc0ae1e --- /dev/null +++ b/docs/howto/config.md @@ -0,0 +1 @@ +## Coming soon diff --git a/docs/howto/discord.md b/docs/howto/discord.md new file mode 100644 index 0000000..dc0ae1e --- /dev/null +++ b/docs/howto/discord.md @@ -0,0 +1 @@ +## Coming soon diff --git a/docs/howto/files.md b/docs/howto/files.md new file mode 100644 index 0000000..c23892e --- /dev/null +++ b/docs/howto/files.md @@ -0,0 +1,233 @@ +# How-To 4: Files + +## Intro +In python-aternos, all files on your Minecraft server +are represented as atfile.AternosFile objects. + +They can be accessed through atfm.FileManager instance, +let's assign it to `fm` variable: +```python +>>> fm = serv.files() +``` + +## List directory contents +```python +>>> root = fm.list_dir('/') +[, ...] +``` + +## Get file by its path +```python +>>> myfile = fm.get_file('/server.properties') + +``` + +## File info +AternosFile object can point to +both a file and a directory +and contain almost the same properties and methods. +(So it's more correct to call it "Object in the server's filesystem", +but I chose an easier name for the class.) + + - `path` - Full path to the file **including trailing** slash and **without leading** slash. + - `name` - Filename with extension **without trailing** slash. + - `dirname` - Full path to the directory which contains the file **without leading** slash. + - `is_file` and `is_dir` - File type in boolean. + - `ftype` - File type in `FileType` enum value: + - `FileType.file` + - `FileType.dir` and `FileType.directory` + - `size` - File size in bytes, float. + `0.0` for directories and + `-1.0` when error occures. + - `deleteable`, `downloadable` and `editable` are explained in the next section. + +### File +```python +>>> f = root[5] + +>>> f.path +'/server.properties' +>>> f.name +'server.properties' +>>> f.dirname +'' + +>>> f.is_file +False +>>> f.is_dir +True + +>>> from python_aternos import FileType +>>> f.ftype == FileType.file +True +>>> f.ftype == FileType.directory +False + +>>> f.size +1240.0 + +>>> f.deleteable +False +>>> f.downloadable +False +>>> f.editable +False +``` + +### Directory +```python +>>> f = root[2] + +>>> f.path +'/config' +>>> f.name +'config' +>>> f.dirname +'' + +>>> f.is_file +False +>>> f.is_dir +True + +>>> from python_aternos import FileType +>>> f.ftype == FileType.file +False +>>> f.ftype == FileType.directory +True +>>> f.ftype == FileType.dir +True + +>>> f.size +0.0 + +>>> f.deleteable +False +>>> f.downloadable +True +>>> f.editable +False +``` + +## Methods + + - `get_text` returns the file content from the Aternos editor page + (opens when you click on the file on web site). + - `set_text` is the same as "Save" button in the Aternos editor. + - `get_content` requests file downloading and + returns file content in `bytes` (not `str`). + If it is a directory, Aternos returns its content in a ZIP file. + - `set_content` like `set_text`, but takes `bytes` as an argument. + - `delete` removes file. + - `create` creates a new file inside this one + (if it's a directory, otherwise throws RuntimeWarning). + +### Deletion and downloading rejection +In [Aternos Files tab](https://aternos.org/files), +some files can be removed with a red button, some of them is protected. +You can check if the file is deleteable this way: +```python +>>> f.deleteable +False +``` +`delete()` method will warn you if it's undeleteable, +and then you'll probably get `FileError` +because of Aternos deletion denial. + +The same thing with `downloadable`. +```python +>>> f.downloadable +True +``` +`get_content()` will warn you if it's undownloadable. +And then you'll get `FileError`. + +And `editable` means that you can click on the file +in Aternos "Files" tab to open editor. +`get_text()` will warn about editing denial. + +### Creating files +Calling `create` method only available for directories +(check it via `f.is_dir`). +It takes two arguments: + + - `name` - name of a new file, + - `ftype` - type of a new file, must be `FileType` enum value: + - `FileType.file` + - `FileType.dir` or `FileType.directory` + +For example, let's create an empty config +for some Forge mod, I'll call it "testmod". +```python +# Import enum +from python_aternos import FileType + +# Get configs directory +conf = fm.get_file('/config') + +# Create empty file +conf.create('testmod.toml', FileType.file) +``` + +### Editing files +Let's edit `ops.json`. +It contains operators nicknames, +so the code below is the same as [Players API](../players/#list-types). + +```python +import json +from python_aternos import Client + +at = Client.from_credentials('username', 'password') +serv = at.list_servers()[0] + +fm = serv.files() +ops = fm.get_file('/ops.json') + +# If editable +use_get_text = True + +# Check +if not ops.editable: + + # One more check + if not ops.downloadable: + print('Error') + exit(0) + + # If downloadable + use_get_text = False + +def read(): + + if use_get_text: + return ops.get_text() + else: + return ops.get_content().decode('utf-8') + +def write(content): + + # set_text and set_content + # uses the same URLs. + # I prefer set_content + + # but we need to convert content to bytes + content = content.encode('utf-8') + + ops.set_content(content) + +# It contains empty list [] by default +oper_raw = read() + +# Convert to Python list +oper_lst = json.loads(oper_raw) + +# Add an operator +oper_lst.append('DarkCat09') + +# Convert back to JSON +oper_new = json.dumps(oper_lst) + +# Write +ops.write(oper_new) +``` diff --git a/docs/howto/players.md b/docs/howto/players.md new file mode 100644 index 0000000..f7e090a --- /dev/null +++ b/docs/howto/players.md @@ -0,0 +1,57 @@ +# How-To 3: Players lists +You can add a player to operators, +include in the whitelist or ban +using this feature. + +## Common usage +It's pretty easy: +```python +from python_aternos import Client, Lists + +... + +whitelist = serv.players(Lists.whl) + +whitelist.add('jeb_') +whitelist.remove('Notch') + +whitelist.list_players() +# ['DarkCat09', 'jeb_'] +``` + +## List types + +| Name | Enum key | +|:----------:|:---------:| +| Whitelist |`Lists.whl`| +| Operators |`Lists.ops`| +| Banned |`Lists.ban`| +|Banned by IP|`Lists.ips`| + +For example, I want to ban someone: +```python +serv.players(Lists.ban).add('someone') +``` + +And give myself operator rights: +```python +serv.players(Lists.ops).add('DarkCat09') +``` + +Unban someone: +```python +serv.players(Lists.ban).remove('someone') +``` + +Unban someone who I banned by IP: +```python +serv.players(Lists.ips).remove('anyone') +``` + +## Caching +If `list_players` doesn't show added players, call it with `cache=False` argument, like list_servers. +```python +lst = serv.players(Lists.ops) +lst.list_players(cache=False) +# ['DarkCat09', 'jeb_'] +``` diff --git a/docs/howto/server.md b/docs/howto/server.md new file mode 100644 index 0000000..0d90e23 --- /dev/null +++ b/docs/howto/server.md @@ -0,0 +1,219 @@ +# How-To 2: Controlling Minecraft server + +In the previous part we logged into account and started a server. +But python-aternos can do much more. + +## Basic methods +```python +from python_aternos import Client + +at = Client.from_credentials('username', 'password') +serv = at.list_servers()[0] + +# Start +serv.start() + +# Stop +serv.stop() + +# Restart +serv.restart() + +# Cancel starting +serv.cancel() + +# Confirm starting +# at the end of a queue +serv.confirm() +``` + +## Starting +### Arguments +`start()` can be called with arguments: + + - headstart (bool): Start server in headstart mode + which allows you to skip all queue. + - accepteula (bool): Automatically accept Mojang EULA. + +If you want to launch your server instantly, use this code: +```python +serv.start(headstart=True) +``` + +### Errors +`start()` raises `ServerStartError` if Aternos denies request. +This object contains an error code, on which depends an error message. + + - EULA was not accepted (code: `eula`) - + remove `accepteula=False` or run `serv.eula()` before startup. + - Server is already running (code: `already`) - + you don't need to start server, it is online. + - Incorrect software version installed (code: `wrongversion`) - + if you have *somehow* installed non-existent software version (e.g. `Vanilla 2.16.5`). + - File server is unavailable (code: `file`) - + problems in Aternos servers, view [https://status.aternos.gmbh](https://status.aternos.gmbh) + - Available storage size limit has been reached (code: `size`) - + files on your Minecraft server have reached 4GB limit + (for exmaple, too much mods or loaded chunks). + +Always wrap `start` into try-catch. +```python +from python_aternos import ServerStartError + +... + +try: + serv.start() +except ServerStartError as err: + print(err.code) # already + print(err.message) # Server is already running +``` + +## Cancellation +Server launching can be cancelled only when you are waiting in a queue. +After queue, when the server starts and writes something to the log, +you can just `stop()` it, not `cancel()`. + +## Server info +```python +>>> serv.address +'test.aternos.me:15172' + +>>> serv.domain +'test.aternos.me' + +>>> serv.subdomain +'test' + +>>> serv.port +15172 + +>>> from python_aternos import Edition +>>> serv.edition +0 +>>> serv.edition == Edition.java +True +>>> serv.edition == Edition.bedrock +False + +>>> serv.software +'Forge' +>>> serv.version +'1.16.5 (36.2.34)' + +>>> serv.players_list +['DarkCat09', 'jeb_'] +>>> serv.players_count +2 +>>> serv.slots +20 + +>>> print('Online:', serv.players_count, 'of', serv.slots) +Online: 2 of 20 + +>>> serv.motd +'§7Welcome to the §9Test Server§7!' + +>>> from python_aternos import Status +>>> serv.css_class +'online' +>>> serv.status +'online' +>>> serv.status_num +1 +>>> serv.status_num == Status.on +True +>>> serv.status_num == Status.off +False +>>> serv.status_num == Status.starting +False + +>>> serv.restart() + +# Title on web site: "Loading" +>>> serv.css_class +'loading' +>>> serv.status +'loading' +>>> serv.status_num +6 +>>> serv.status_num == Status.loading +True +>>> serv.status_num == Status.preparing +False +>>> serv.status_num == Status.starting +False + +# Title on web site: "Preparing" +>>> serv.css_class +'loading' +>>> serv.status +'preparing' +>>> serv.status_num +10 +>>> serv.status_num == Status.preparing +True +>>> serv.status_num == Status.starting +False +>>> serv.status_num == Status.on +False + +# Title on web site: "Starting" +>>> serv.css_class +'loading starting' +>>> serv.status +'starting' +>>> serv.status_num +2 +>>> serv.status_num == Status.starting +True +>>> serv.status_num == Status.on +False + +>>> serv.ram +2600 +``` + +## Changing subdomain and MOTD +To change server subdomain or Message-of-the-Day, +just assign a new value to the corresponding fields: +```python +serv.subdomain = 'new-test-server123' +serv.motd = 'Welcome to the New Test Server!' +``` + +## Updating status +python-aternos don't refresh server information by default. +This can be done with [WebSockets API](websocket) automatically +(but it will be explained later in the 6th part of how-to guide), +or with `fetch()` method manually (much easier). + +`fetch()` called also when an AternosServer object is created +to get info about the server: + + - full address, + - MOTD, + - software, + - connected players, + - status, + - etc. + +Use it if you want to see new data one time: +```python +import time +from python_aternos import Client + +at = Client.from_credentials('username', 'password') +serv = at.list_servers()[0] + +# Start +serv.start() +# Wait 10 sec +time.sleep(10) +# Check +serv.fetch() +print('Server is', serv.status) # Server is online +``` +But this method is **not** a good choice if you want to get real-time updates. +Read [How-To 6: Real-time updates](websocket) about WebSockets API +and use it instead of refreshing data in a while-loop. diff --git a/docs/howto/websocket.md b/docs/howto/websocket.md new file mode 100644 index 0000000..dc0ae1e --- /dev/null +++ b/docs/howto/websocket.md @@ -0,0 +1 @@ +## Coming soon diff --git a/docs/index.md b/docs/index.md index c7572e5..cad3b6b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,153 @@ -![Python-Aternos Logo](https://i.ibb.co/3RXcXJ1/aternos-400.png) -# Python Aternos +
+ Python Aternos Logo +

+ Python Aternos + +

+
+ An unofficial Aternos API written in Python. It uses [aternos](https://aternos.org/)' private API and html parsing. + +Python Aternos supports: + + - Logging in to account with password (plain or hashed) or `ATERNOS_SESSION` cookie value. + - Saving session to the file and restoring. + - Changing username, email and password. + - Parsing Minecraft servers list. + - Parsing server info by its ID. + - Starting/stoping server, restarting, confirming/cancelling launch. + - Updating server info in real-time (view WebSocket API). + - Changing server subdomain and MOTD (message-of-the-day). + - Managing files, settings, players (whitelist, operators, etc.) + +> **Warning** +> +> According to the Aternos' [Terms of Service §5.2e](https://aternos.gmbh/en/aternos/terms#:~:text=Automatically%20accessing%20our%20website%20or%20automating%20actions%20on%20our%20website.), +> you must not use any software or APIs for automated access, +> beacuse they don't receive money from advertisting in this case. +> +> I always try to hide automated python-aternos requests +> using browser-specific headers/cookies, +> but you should make backups to restore your world +> if your account will be banned +> (view [#16](https://github.com/DarkCat09/python-aternos/issues/16) +> and [#46](https://github.com/DarkCat09/python-aternos/issues/46)). + +## Install + +### Common +```bash +$ pip install python-aternos +``` +> **Note** for Windows users +> +> Install `lxml` package from [here](https://www.lfd.uci.edu/~gohlke/pythonlibs/#lxml) +> if you have problems with it, and then execute +> `pip install --no-deps python-aternos` + +### Development +```bash +$ git clone https://github.com/DarkCat09/python-aternos.git +$ cd python-aternos +$ pip install -e . +``` + +## Usage +To use Aternos API in your Python script, import it +and login with your username and password or MD5. + +Then request the servers list using `list_servers()`. +You can start/stop your Aternos server, calling `start()` or `stop()`. + +Here is an example how to use the API: +```python +# Import +from python_aternos import Client + +# Log in +aternos = Client.from_credentials('example', 'test123') +# ----OR---- +aternos = Client.from_hashed('example', 'cc03e747a6afbbcbf8be7668acfebee5') +# ----OR---- +aternos = Client.restore_session() + +# Returns AternosServer list +servs = aternos.list_servers() + +# Get the first server by the 0 index +myserv = servs[0] + +# Start +myserv.start() +# Stop +myserv.stop() + +# You can also find server by IP +testserv = None +for serv in servs: + if serv.address == 'test.aternos.org': + testserv = serv + +if testserv is not None: + # Prints a server softaware and its version + # (for example, "Vanilla 1.12.2") + print(testserv.software, testserv.version) + # Starts server + testserv.start() +``` + +## [More examples](https://github.com/DarkCat09/python-aternos/tree/main/examples) + +## [Documentation](https://darkcat09.codeberg.page/aternos-docs/) + +## [How-To Guide](https://darkcat09.codeberg.page/aternos-docs/howto/auth) + +## Changelog +|Version|Description | +|:-----:|:-----------| +|v0.1|The first release.| +|v0.2|Fixed import problem.| +|v0.3|Implemented files API, added typization.| +|v0.4|Implemented configuration API, some bugfixes.| +|v0.5|The API was updated corresponding to new Aternos security methods. Huge thanks to [lusm554](https://github.com/lusm554).| +|**v0.6/v1.0.0**|Code refactoring, websockets API and session saving to prevent detecting automation access.| +|v1.0.x|Lots of bugfixes, changed versioning (SemVer).| +|v1.1.x|Documentation, unit tests, pylint, bugfixes, changes in atwss.| +|**v1.1.2/v2.0.0**|Solution for [#25](https://github.com/DarkCat09/python-aternos/issues/25) (Cloudflare bypassing), bugfixes in JS parser.| +|v2.0.x|Documentation, automatically saving/restoring session, improvements in Files API.| +|v2.1.x|Fixes in the implementation of websockets API.| +|**v2.2.x**|Using Node.js as a JS interpreter if it's installed.| +|v3.0.x|Full implementation of config and software API.| +|v3.1.x|Shared access API and Google Drive backups.| + +## License +[License Notice](NOTICE): +``` +Copyright 2021-2022 All contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` diff --git a/docs/reference.md b/docs/reference.md deleted file mode 100644 index e3e878e..0000000 --- a/docs/reference.md +++ /dev/null @@ -1,39 +0,0 @@ -::: python_aternos.atclient - options: - show_source: false - -::: python_aternos.atserver - options: - show_source: false - -::: python_aternos.atplayers - options: - show_source: false - -::: python_aternos.atconf - options: - show_source: false - -::: python_aternos.atfm - options: - show_source: false - -::: python_aternos.atfile - options: - show_source: false - -::: python_aternos.atwss - options: - show_source: false - -::: python_aternos.atconnect - options: - show_source: false - -::: python_aternos.atjsparse - options: - show_source: false - -::: python_aternos.aterrors - options: - show_source: false diff --git a/docs/reference/atclient.md b/docs/reference/atclient.md new file mode 100644 index 0000000..3b5cb96 --- /dev/null +++ b/docs/reference/atclient.md @@ -0,0 +1,2 @@ +## `atclient` (Entry point) +### ::: python_aternos.atclient diff --git a/docs/reference/atconf.md b/docs/reference/atconf.md new file mode 100644 index 0000000..f684279 --- /dev/null +++ b/docs/reference/atconf.md @@ -0,0 +1,2 @@ +## atconf +### ::: python_aternos.atconf diff --git a/docs/reference/atconnect.md b/docs/reference/atconnect.md new file mode 100644 index 0000000..2e38822 --- /dev/null +++ b/docs/reference/atconnect.md @@ -0,0 +1,2 @@ +## atconnect +### ::: python_aternos.atconnect diff --git a/docs/reference/aterrors.md b/docs/reference/aterrors.md new file mode 100644 index 0000000..4088108 --- /dev/null +++ b/docs/reference/aterrors.md @@ -0,0 +1,2 @@ +## aterrors +### ::: python_aternos.aterrors diff --git a/docs/reference/atfile.md b/docs/reference/atfile.md new file mode 100644 index 0000000..b408262 --- /dev/null +++ b/docs/reference/atfile.md @@ -0,0 +1,2 @@ +## atfile +### ::: python_aternos.atfile diff --git a/docs/reference/atfm.md b/docs/reference/atfm.md new file mode 100644 index 0000000..1215e43 --- /dev/null +++ b/docs/reference/atfm.md @@ -0,0 +1,2 @@ +## atfm +### ::: python_aternos.atfm diff --git a/docs/reference/atjsparse.md b/docs/reference/atjsparse.md new file mode 100644 index 0000000..171705a --- /dev/null +++ b/docs/reference/atjsparse.md @@ -0,0 +1,2 @@ +## atjsparse +### ::: python_aternos.atjsparse diff --git a/docs/reference/atplayers.md b/docs/reference/atplayers.md new file mode 100644 index 0000000..7be9ec8 --- /dev/null +++ b/docs/reference/atplayers.md @@ -0,0 +1,2 @@ +## `atplayers` +### ::: python_aternos.atplayers diff --git a/docs/reference/atserver.md b/docs/reference/atserver.md new file mode 100644 index 0000000..beb9a51 --- /dev/null +++ b/docs/reference/atserver.md @@ -0,0 +1,2 @@ +## `atserver` +### ::: python_aternos.atserver diff --git a/docs/reference/atwss.md b/docs/reference/atwss.md new file mode 100644 index 0000000..efd4486 --- /dev/null +++ b/docs/reference/atwss.md @@ -0,0 +1,2 @@ +## atwss +### ::: python_aternos.atwss diff --git a/examples/files_example.py b/examples/files_example.py index e865901..d032a50 100644 --- a/examples/files_example.py +++ b/examples/files_example.py @@ -10,7 +10,8 @@ files = s.files() while True: - cmd = input('> ').strip().lower() + inp = input('> ').strip() + cmd = inp.lower() if cmd == 'help': print( @@ -25,8 +26,8 @@ while True: break if cmd.startswith('list'): - path = cmd.removeprefix('list').strip() - directory = files.listdir(path) + path = inp[4:].strip() + directory = files.list_dir(path) print(path, 'contains:') for file in directory: diff --git a/mkdocs.yml b/mkdocs.yml index 8c3d367..127b053 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,12 +2,40 @@ site_name: Python-Aternos theme: name: readthedocs + sticky_navigation: false + include_homepage_in_sidebar: false + prev_next_buttons_location: both + +markdown_extensions: + - toc: + permalink: '#' plugins: - search - - mkdocstrings + - mkdocstrings: + handlers: + python: + options: + show_source: false nav: - - index.md - - howto.md - - reference.md + - Home: 'index.md' + - 'How-To Guide': + - 'Logging in': 'howto/auth.md' + - 'Servers': 'howto/server.md' + - 'Whitelist': 'howto/players.md' + - 'Files': 'howto/files.md' + - 'Settings': 'howto/config.md' + - 'Real-time updates': 'howto/websocket.md' + - 'Discord bot': 'howto/discord.md' + - 'API Reference': + - atclient: 'reference/atclient.md' + - atserver: 'reference/atserver.md' + - atplayers: 'reference/atplayers.md' + - atconf: 'reference/atconf.md' + - atfm: 'reference/atfm.md' + - atfile: 'reference/atfile.md' + - atconnect: 'reference/atconnect.md' + - atjsparse: 'reference/atjsparse.md' + - aterrors: 'reference/aterrors.md' + - atwss: 'reference/atwss.md' diff --git a/python_aternos/atclient.py b/python_aternos/atclient.py index 016c213..65e8ae5 100644 --- a/python_aternos/atclient.py +++ b/python_aternos/atclient.py @@ -4,6 +4,7 @@ and allows to manage your account""" import os import re import hashlib +import logging from typing import List, Optional @@ -16,18 +17,24 @@ from .aterrors import CredentialsError class Client: - """Aternos API Client class object of which contains user's auth data - - :param atconn: :class:`python_aternos.atconnect.AternosConnect` - instance with initialized Aternos session - :type atconn: python_aternos.atconnect.AternosConnect - """ + """Aternos API Client class object + of which contains user's auth data""" def __init__( self, atconn: AternosConnect, servers: Optional[List[str]] = None) -> None: + """Aternos API Client class object + of which contains user's auth data + + Args: + atconn (AternosConnect): + AternosConnect instance with initialized Aternos session + servers (Optional[List[str]], optional): + List with servers IDs + """ + self.atconn = atconn self.parsed = False self.servers: List[AternosServer] = [] @@ -36,24 +43,38 @@ class Client: self.refresh_servers(servers) @classmethod - def from_hashed(cls, username: str, md5: str): + def from_hashed( + cls, + username: str, + md5: str, + sessions_dir: str = '~'): - """Log in to Aternos with a username and a hashed password + """Log in to an Aternos account with + a username and a hashed password - :param username: Your username - :type username: str - :param md5: Your password hashed with MD5 - :type md5: str - :raises CredentialsError: If the API - doesn't return a valid session cookie - :return: Client instance - :rtype: python_aternos.Client + Args: + username (str): Your username + md5 (str): Your password hashed with MD5 + sessions_dir (str): Path to the directory + where session will be automatically saved + + Raises: + CredentialsError: If the API didn't + return a valid session cookie """ atconn = AternosConnect() atconn.parse_token() atconn.generate_sec() + secure = cls.secure_name(username) + filename = f'{sessions_dir}/.at_{secure}' + + try: + return cls.restore_session(filename) + except (OSError, CredentialsError): + pass + credentials = { 'user': username, 'password': md5 @@ -69,23 +90,36 @@ class Client: 'Check your username and password' ) - return cls(atconn) + obj = cls(atconn) + + try: + obj.save_session(filename) + except OSError: + pass + + return obj @classmethod - def from_credentials(cls, username: str, password: str): + def from_credentials( + cls, + username: str, + password: str, + sessions_dir: str = '~'): """Log in to Aternos with a username and a plain password - :param username: Your username - :type username: str - :param password: Your password without any encryption - :type password: str - :return: Client instance - :rtype: python_aternos.Client + Args: + username (str): Your username + password (str): Your password without any encryption + sessions_dir (str): Path to the directory + where session will be automatically saved """ md5 = Client.md5encode(password) - return cls.from_hashed(username, md5) + return cls.from_hashed( + username, md5, + sessions_dir + ) @classmethod def from_session( @@ -95,10 +129,8 @@ class Client: """Log in to Aternos using a session cookie value - :param session: Value of ATERNOS_SESSION cookie - :type session: str - :return: Client instance - :rtype: python_aternos.Client + Args: + session (str): Value of ATERNOS_SESSION cookie """ atconn = AternosConnect() @@ -113,18 +145,28 @@ class Client: """Log in to Aternos using a saved ATERNOS_SESSION cookie - :param file: File where a session cookie - was saved, deafults to `~/.aternos` - :type file: str, optional - :return: Client instance - :rtype: python_aternos.Client + Args: + file (str, optional): File where a session cookie was saved """ file = os.path.expanduser(file) + logging.debug(f'Restoring session from {file}') + + if not os.path.exists(file): + raise FileNotFoundError() + with open(file, 'rt', encoding='utf-8') as f: - saved = f.read().replace('\r\n', '\n').split('\n') + saved = f.read() \ + .strip() \ + .replace('\r\n', '\n') \ + .split('\n') session = saved[0].strip() + if session == '': + raise CredentialsError( + 'Unable to read session cookie, ' + 'the first line is empty' + ) if len(saved) > 1: return cls.from_session( @@ -139,15 +181,36 @@ class Client: """Encodes the given string with MD5 - :param passwd: String to encode - :type passwd: str - :return: Hexdigest hash of the string in lowercase - :rtype: str + Args: + passwd (str): String to encode + + Returns: + Hexdigest hash of the string in lowercase """ encoded = hashlib.md5(passwd.encode('utf-8')) return encoded.hexdigest().lower() + @staticmethod + def secure_name(filename: str, repl: str = '_') -> str: + + """Replaces unsecure characters + in filename to underscore or `repl` + + Args: + filename (str): Filename + repl (str, optional): Replacement + for unsafe characters + + Returns: + str: Secure filename + """ + + return re.sub( + r'[^A-Za-z0-9_-]', + repl, filename + ) + def save_session( self, file: str = '~/.aternos', @@ -155,17 +218,16 @@ class Client: """Saves an ATERNOS_SESSION cookie to a file - :param file: File where a session cookie - must be saved, defaults to `~/.aternos` - :type file: str, optional - :param incl_servers: If the function - should include the servers IDs to - reduce API requests count (recommended), - defaults to True - :type incl_servers: bool, optional + Args: + file (str, optional): File where a session cookie must be saved + incl_servers (bool, optional): If the function + should include the servers IDs to + reduce API requests count (recommended) """ file = os.path.expanduser(file) + logging.debug(f'Saving session to {file}') + with open(file, 'wt', encoding='utf-8') as f: f.write(self.atconn.atsession + '\n') @@ -179,11 +241,12 @@ class Client: """Parses a list of your servers from Aternos website - :param cache: If the function should use - cached servers list (recommended), defaults to True - :type cache: bool, optional - :return: List of :class:`python_aternos.atserver.AternosServer` objects - :rtype: list + Args: + cache (bool, optional): If the function should use + cached servers list (recommended) + + Returns: + List of AternosServer objects """ if cache and self.parsed: @@ -204,10 +267,10 @@ class Client: def refresh_servers(self, ids: List[str]) -> None: """Replaces cached servers list creating - :class:`AternosServer` objects by given IDs + AternosServer objects by given IDs - :param ids: Servers unique identifiers - :type ids: List[str] + Args: + ids (List[str]): Servers unique identifiers """ self.servers = [] @@ -217,6 +280,7 @@ class Client: if servid == '': continue + logging.debug(f'Adding server {servid}') srv = AternosServer(servid, self.atconn) self.servers.append(srv) @@ -225,17 +289,18 @@ class Client: def get_server(self, servid: str) -> AternosServer: """Creates a server object from the server ID. - Use this instead of list_servers if you know the ID to save some time. + Use this instead of list_servers + if you know the ID to save some time. - :return: :class:`python_aternos.atserver.AternosServer` object - :rtype: python_aternos.atserver.AternosServer + Returns: + AternosServer object """ return AternosServer(servid, self.atconn) def logout(self) -> None: - """Logouts from Aternos account""" + """Log out from Aternos account""" self.atconn.request_cloudflare( 'https://aternos.org/panel/ajax/account/logout.php', @@ -246,8 +311,8 @@ class Client: """Changes a username in your Aternos account - :param value: New username - :type value: str + Args: + value (str): New username """ self.atconn.request_cloudflare( @@ -259,10 +324,12 @@ class Client: """Changes an e-mail in your Aternos account - :param value: New e-mail - :type value: str - :raises ValueError: If an invalid - e-mail address was passed to the function + Args: + value (str): New e-mail + + Raises: + ValueError: If an invalid e-mail address + was passed to the function """ email = re.compile( @@ -280,14 +347,27 @@ class Client: """Changes a password in your Aternos account - :param old: Old password - :type old: str - :param new: New password - :type new: str + Args: + old (str): Old password + new (str): New password + """ + + self.change_password_hashed( + Client.md5encode(old), + Client.md5encode(new), + ) + + def change_password_hashed(self, old: str, new: str) -> None: + + """Changes a password in your Aternos account. + Unlike `change_password`, this function + takes hashed passwords as arguments + + Args: + old (str): Old password hashed with MD5 + new (str): New password hashed with MD5 """ - old = Client.md5encode(old) - new = Client.md5encode(new) self.atconn.request_cloudflare( 'https://aternos.org/panel/ajax/account/password.php', 'POST', data={ diff --git a/python_aternos/atconf.py b/python_aternos/atconf.py index 8f2b44a..bcbf49a 100644 --- a/python_aternos/atconf.py +++ b/python_aternos/atconf.py @@ -121,22 +121,25 @@ convert = { class AternosConfig: - """Class for editing server settings - - :param atserv: :class:`python_aternos.atserver.AternosServer` object - :type atserv: python_aternos.atserver.AternosServer - """ + """Class for editing server settings""" def __init__(self, atserv: 'AternosServer') -> None: + """Class for editing server settings + + Args: + atserv (python_aternos.atserver.AternosServer): + atserver.AternosServer object + """ + self.atserv = atserv def get_timezone(self) -> str: """Parses timezone from options page - :return: Area/Location - :rtype: str + Returns: + Area/Location """ optreq = self.atserv.atserver_request( @@ -154,10 +157,12 @@ class AternosConfig: """Sets new timezone - :param value: New timezone - :type value: str - :raises ValueError: If given string - doesn't match Area/Location format + Args: + value (str): New timezone + + Raises: + ValueError: If given string doesn't + match `Area/Location` format """ matches_tz = tzcheck.search(value) @@ -176,8 +181,8 @@ class AternosConfig: """Parses Java version from options page - :return: Java image version - :rtype: int + Returns: + Java image version """ optreq = self.atserv.atserver_request( @@ -198,8 +203,8 @@ class AternosConfig: """Sets new Java version - :param value: New Java image version - :type value: int + Args: + value (int): New Java image version """ self.atserv.atserver_request( @@ -215,10 +220,9 @@ class AternosConfig: """Sets server.properties option - :param option: Option name - :type option: str - :param value: New value - :type value: Any + Args: + option (str): Option name + value (Any): New value """ self.__set_prop( @@ -230,12 +234,15 @@ class AternosConfig: """Parses all server.properties from options page - :param proptyping: If the returned dict should contain value - that matches property type (e.g. max-players will be int) - instead of string, defaults to True - :type proptyping: bool, optional - :return: Server.properties dict - :rtype: Dict[str,Any] + Args: + proptyping (bool, optional): + If the returned dict should + contain value that matches + property type (e.g. max-players will be int) + instead of string + + Returns: + `server.properties` dictionary """ return self.__get_all_props('https://aternos.org/options', proptyping) @@ -244,8 +251,9 @@ class AternosConfig: """Updates server.properties options with the given dict - :param props: Dict with properties `{key:value}` - :type props: Dict[str,Any] + Args: + props (Dict[str,Any]): + Dictionary with `{key:value}` properties """ for key in props: @@ -261,16 +269,12 @@ class AternosConfig: """Sets level.dat option for specified world - :param option: Option name - :type option: Union[WorldOpts,WorldRules] - :param value: New value - :type value: Any - :param gamerule: If the option - is a gamerule, defaults to False - :type gamerule: bool, optional - :param world: Name of the world which - level.dat must be edited, defaults to 'world' - :type world: str, optional + Args: + option (Union[WorldOpts, WorldRules]): Option name + value (Any): New value + gamerule (bool, optional): If the option is a gamerule + world (str, optional): Name of the world which + `level.dat` must be edited """ prefix = DAT_PREFIX @@ -289,14 +293,16 @@ class AternosConfig: """Parses level.dat from specified world's options page - :param world: Name of the world, defaults to 'world' - :type world: str, optional - :param proptyping: If the returned dict should contain the value - that matches property type (e.g. randomTickSpeed will be bool) - instead of string, defaults to True - :type proptyping: bool, optional - :return: Level.dat dict - :rtype: Dict[str,Any] + Args: + world (str, optional): Name of the worl + proptyping (bool, optional): + If the returned dict should + contain the value that matches + property type (e.g. randomTickSpeed will be bool) + instead of string + + Returns: + `level.dat` options dictionary """ return self.__get_all_props( @@ -312,11 +318,11 @@ class AternosConfig: """Sets level.dat options from the dictionary for the specified world - :param props: Level.dat options - :type props: Dict[Union[WorldOpts, WorldRules], Any] - :param world: name of the world which - level.dat must be edited, defaults to 'world' - :type world: str + Args: + props (Dict[Union[WorldOpts, WorldRules], Any]): + `level.dat` options + world (str): name of the world which + `level.dat` must be edited """ for key in props: diff --git a/python_aternos/atconnect.py b/python_aternos/atconnect.py index be7f14d..8f0454f 100644 --- a/python_aternos/atconnect.py +++ b/python_aternos/atconnect.py @@ -7,7 +7,9 @@ import logging from functools import partial from typing import Optional, Union -from requests import Response +from typing import Dict, Any + +import requests from cloudscraper import CloudScraper @@ -23,14 +25,12 @@ REQUA = \ class AternosConnect: - """ - Class for sending API requests bypass Cloudflare - and parsing responses""" + """Class for sending API requests + bypass Cloudflare and parsing responses""" def __init__(self) -> None: self.session = CloudScraper() - self.atsession = '' self.sec = '' self.token = '' @@ -39,12 +39,14 @@ class AternosConnect: """Parses Aternos ajax token that is needed for most requests - :raises RuntimeWarning: If the parser - can not find tag in HTML response - :raises CredentialsError: If the parser - is unable to extract ajax token in HTML - :return: Aternos ajax token - :rtype: str + Raises: + RuntimeWarning: If the parser can not + find `` tag in HTML response + TokenError: If the parser is unable + to extract ajax token from HTML + + Returns: + Aternos ajax token """ loginpage = self.request_cloudflare( @@ -71,7 +73,10 @@ class AternosConnect: try: text = pagehead.decode('utf-8', 'replace') js_code = re.findall(r'\(\(\)(.*?)\)\(\);', text) - token_func = js_code[1] if len(js_code) > 1 else js_code[0] + + token_func = js_code[0] + if len(js_code) > 1: + token_func = js_code[1] ctx = atjsparse.exec_js(token_func) self.token = ctx.window['AJAX_TOKEN'] @@ -88,8 +93,8 @@ class AternosConnect: """Generates Aternos SEC token which is also needed for most API requests - :return: Random SEC key:value string - :rtype: str + Returns: + Random SEC `key:value` string """ randkey = self.generate_aternos_rand() @@ -107,10 +112,11 @@ class AternosConnect: """Generates a random string using Aternos algorithm from main.js file - :param randlen: Random string length, defaults to 16 - :type randlen: int, optional - :return: Random string for SEC token - :rtype: str + Args: + randlen (int, optional): Random string length + + Returns: + Random string for SEC token """ # a list with randlen+1 empty strings: @@ -129,16 +135,15 @@ class AternosConnect: """Converts an integer to specified base - :param num: Integer in any base to convert. - If it is a float started with `0,`, - zero and comma will be removed to get int - :type num: Union[int,float,str] - :param base: New base - :type base: int - :param frombase: Given number base, defaults to 10 - :type frombase: int, optional - :return: Number converted to a specified base - :rtype: str + Args: + num (Union[int,float,str]): Integer in any base to convert. + If it is a float starting with `0.`, + zero and point will be removed to get int + base (int): New base + frombase (int, optional): Given number base + + Returns: + Number converted to a specified base """ if isinstance(num, str): @@ -159,40 +164,35 @@ class AternosConnect: def request_cloudflare( self, url: str, method: str, - params: Optional[dict] = None, - data: Optional[dict] = None, - headers: Optional[dict] = None, - reqcookies: Optional[dict] = None, + params: Optional[Dict[Any, Any]] = None, + data: Optional[Dict[Any, Any]] = None, + headers: Optional[Dict[Any, Any]] = None, + reqcookies: Optional[Dict[Any, Any]] = None, sendtoken: bool = False, - retry: int = 5) -> Response: + retry: int = 5) -> requests.Response: """Sends a request to Aternos API bypass Cloudflare - :param url: Request URL - :type url: str - :param method: Request method, must be GET or POST - :type method: str - :param params: URL parameters, defaults to None - :type params: Optional[dict], optional - :param data: POST request data, if the method is GET, - this dict will be combined with params, defaults to None - :type data: Optional[dict], optional - :param headers: Custom headers, defaults to None - :type headers: Optional[dict], optional - :param reqcookies: Cookies only for this request, defaults to None - :type reqcookies: Optional[dict], optional - :param sendtoken: If the ajax and SEC token - should be sent, defaults to False - :type sendtoken: bool, optional - :param retry: How many times parser must retry - connection to API bypass Cloudflare, defaults to 5 - :type retry: int, optional - :raises CloudflareError: - When the parser has exceeded retries count - :raises NotImplementedError: - When the specified method is not GET or POST - :return: API response - :rtype: requests.Response + Args: + url (str): Request URL + method (str): Request method, must be GET or POST + params (Optional[Dict[Any, Any]], optional): URL parameters + data (Optional[Dict[Any, Any]], optional): POST request data, + if the method is GET, this dict will be combined with params + headers (Optional[Dict[Any, Any]], optional): Custom headers + reqcookies (Optional[Dict[Any, Any]], optional): + Cookies only for this request + sendtoken (bool, optional): If the ajax and SEC token + should be sent + retry (int, optional): How many times parser must retry + connection to API bypass Cloudflare + + Raises: + CloudflareError: When the parser has exceeded retries count + NotImplementedError: When the specified method is not GET or POST + + Returns: + API response """ if retry <= 0: @@ -202,12 +202,6 @@ class AternosConnect: self.session = CloudScraper() self.session.cookies.update(old_cookies) - try: - self.atsession = self.session.cookies['ATERNOS_SESSION'] - except KeyError: - # don't rewrite atsession value - pass - params = params or {} data = data or {} headers = headers or {} @@ -276,3 +270,17 @@ class AternosConnect: req.raise_for_status() return req + + @property + def atsession(self) -> str: + + """Aternos session cookie, + empty string if not logged in + + Returns: + Session cookie + """ + + return self.session.cookies.get( + 'ATERNOS_SESSION', '' + ) diff --git a/python_aternos/aterrors.py b/python_aternos/aterrors.py index 882e09b..db04b61 100644 --- a/python_aternos/aterrors.py +++ b/python_aternos/aterrors.py @@ -28,27 +28,24 @@ class TokenError(AternosError): class ServerError(AternosError): - """Common class for server errors - - :param reason: Code which contains error reason - :type reason: str - :param message: Error message, defaults to '' - :type message: str, optional - """ + """Common class for server errors""" def __init__(self, reason: str, message: str = '') -> None: + """Common class for server errors + + Args: + reason (str): Code which contains error reason + message (str, optional): Error message + """ + self.reason = reason super().__init__(message) class ServerStartError(AternosError): - """Raised when Aternos can not start Minecraft server - - :param reason: Code which contains error reason - :type reason: str - """ + """Raised when Aternos can not start Minecraft server""" MESSAGE: Final = 'Unable to start server, code: {}' reason_msg = { @@ -57,22 +54,31 @@ class ServerStartError(AternosError): 'EULA was not accepted. ' 'Use start(accepteula=True)', - 'already': 'Server is already running', + 'already': 'Server has already started', 'wrongversion': 'Incorrect software version installed', 'file': 'File server is unavailbale, ' 'view https://status.aternos.gmbh', - 'size': 'Available storage size limit (4 GB) was reached' + 'size': 'Available storage size limit (4 GB) has been reached' } def __init__(self, reason: str) -> None: + """Raised when Aternos + can not start Minecraft server + + Args: + reason (str): + Code which contains error reason + """ + super().__init__( reason, self.reason_msg.get( - reason, self.MESSAGE.format(reason) + reason, + self.MESSAGE.format(reason) ) ) diff --git a/python_aternos/atfile.py b/python_aternos/atfile.py index 6b7cbda..1a717c1 100644 --- a/python_aternos/atfile.py +++ b/python_aternos/atfile.py @@ -19,109 +19,214 @@ class FileType(enum.IntEnum): file = 0 directory = 1 + dir = 1 class AternosFile: - """File class which contains info about its path, type and size - - :param atserv: :class:`python_aternos.atserver.AternosServer` instance - :type atserv: python_aternos.atserver.AternosServer - :param path: Path to the file - :type path: str - :param name: Filename - :type name: str - :param ftype: File or directory - :type ftype: python_aternos.atfile.FileType - :param size: File size, defaults to 0 - :type size: Union[int,float], optional - """ + """File class which contains info + about its path, type and size""" def __init__( self, atserv: 'AternosServer', - path: str, name: str, + path: str, rmable: bool, + dlable: bool, editable: bool, ftype: FileType = FileType.file, size: Union[int, float] = 0) -> None: + """File class which contains info + about its path, type and size + + Args: + atserv (python_aternos.atserver.AternosServer): + atserver.AternosServer instance + path (str): Absolute path to the file + rmable (bool): Is the file deleteable (removeable) + dlable (bool): Is the file downloadable + ftype (python_aternos.atfile.FileType): File or directory + size (Union[int,float], optional): File size + """ + + path = path.lstrip('/') + path = '/' + path + self.atserv = atserv - self._path = path.lstrip('/') - self._name = name - self._full = path + name + + self._path = path + self._name = path[path.rfind('/') + 1:] + self._dirname = path[:path.rfind('/')] + + self._deleteable = rmable + self._downloadable = dlable + self._editable = editable + self._ftype = ftype self._size = float(size) + def create( + self, + name: str, + ftype: FileType = FileType.file) -> None: + + """Creates a file or a directory inside this one + + Args: + name (str): Filename + ftype (FileType, optional): File type + + Raises: + RuntimeWarning: Messages about probabilty of FileError + (if `self` file object is not a directory) + FileError: If Aternos denied file creation + """ + + if self.is_file: + raise RuntimeWarning( + 'Creating files only available ' + 'inside directories' + ) + + name = name.strip().replace('/', '_') + req = self.atserv.atserver_request( + 'https://aternos.org/panel/ajax/files/create.php', + 'POST', data={ + 'file': f'{self._path}/{name}', + 'type': 'file' + if ftype == FileType.file + else 'directory' + } + ) + + if req.content == b'{"success":false}': + raise FileError('Unable to create a file') + def delete(self) -> None: - """Deletes the file""" + """Deletes the file - self.atserv.atserver_request( + Raises: + RuntimeWarning: Message about probability of FileError + FileError: If deleting this file is disallowed by Aternos + """ + + if not self._deleteable: + raise RuntimeWarning( + 'The file seems to be protected (undeleteable). ' + 'Always check it before calling delete()' + ) + + req = self.atserv.atserver_request( 'https://aternos.org/panel/ajax/delete.php', - 'POST', data={'file': self._full}, + 'POST', data={'file': self._path}, sendtoken=True ) + if req.content == b'{"success":false}': + raise FileError('Unable to delete the file') + def get_content(self) -> bytes: """Requests file content in bytes (downloads it) - :raises FileError: If downloading - the file is not allowed by Aternos - :return: File content - :rtype: bytes + Raises: + RuntimeWarning: Message about probability of FileError + FileError: If downloading this file is disallowed by Aternos + + Returns: + File content """ + if not self._downloadable: + raise RuntimeWarning( + 'The file seems to be undownloadable. ' + 'Always check it before calling get_content()' + ) + file = self.atserv.atserver_request( 'https://aternos.org/panel/ajax/files/download.php', 'GET', params={ - 'file': self._full + 'file': self._path } ) + if file.content == b'{"success":false}': - raise FileError('Unable to download the file. Try to get text') + raise FileError( + 'Unable to download the file. ' + 'Try to get text' + ) + return file.content def set_content(self, value: bytes) -> None: - """Modifies the file content + """Modifies file content - :param value: New content - :type value: bytes + Args: + value (bytes): New content + + Raises: + FileError: If Aternos denied file saving """ - self.atserv.atserver_request( + req = self.atserv.atserver_request( 'https://aternos.org/panel/ajax/save.php', 'POST', data={ - 'file': self._full, + 'file': self._path, 'content': value }, sendtoken=True ) + if req.content == b'{"success":false}': + raise FileError('Unable to save the file') + def get_text(self) -> str: """Requests editing the file as a text - (try it if downloading is disallowed) - :return: File text content - :rtype: str + Raises: + RuntimeWarning: Message about probability of FileError + FileError: If unable to parse text from response + + Returns: + File text content """ + if not self._editable: + raise RuntimeWarning( + 'The file seems to be uneditable. ' + 'Always check it before calling get_text()' + ) + + if self.is_dir: + raise RuntimeWarning( + 'Use get_content() to download ' + 'a directory as a ZIP file!' + ) + + filepath = self._path.lstrip("/") editor = self.atserv.atserver_request( - f'https://aternos.org/files/{self._full.lstrip("/")}', 'GET' + f'https://aternos.org/files/{filepath}', 'GET' ) edittree = lxml.html.fromstring(editor.content) + editblock = edittree.xpath('//div[@id="editor"]') - editblock = edittree.xpath('//div[@id="editor"]')[0] - return editblock.text_content() + if len(editblock) < 1: + raise FileError( + 'Unable to open editor. ' + 'Try to get file content' + ) + + return editblock[0].text_content() def set_text(self, value: str) -> None: """Modifies the file content, - but unlike set_content takes - a string as a new value + but unlike `set_content` takes + a string as an argument - :param value: New content - :type value: str + Args: + value (str): New content """ self.set_content(value.encode('utf-8')) @@ -129,11 +234,12 @@ class AternosFile: @property def path(self) -> str: - """Path to a directory which - contains the file, without leading slash + """Abslute path to the file + without leading slash + including filename - :return: Full path to directory - :rtype: str + Returns: + Full path to the file """ return self._path @@ -141,60 +247,105 @@ class AternosFile: @property def name(self) -> str: - """Filename including extension + """Filename with extension - :return: Filename - :rtype: str + Returns: + Filename """ return self._name @property - def full(self) -> str: + def dirname(self) -> str: - """Absolute path to the file, - without leading slash + """Full path to the directory + which contains the file + without leading slash. + Empty path means root (`/`) - :return: Full path - :rtype: str + Returns: + Path to the directory """ - return self._full + return self._dirname + + @property + def deleteable(self) -> bool: + + """True if the file can be deleted, + otherwise False + + Returns: + Can the file be deleted + """ + + return self._deleteable + + @property + def downloadable(self) -> bool: + + """True if the file can be downloaded, + otherwise False + + Returns: + Can the file be downloaded + """ + + return self._downloadable + + @property + def editable(self) -> bool: + + """True if the file can be + opened in Aternos editor, + otherwise False + + Returns: + Can the file be edited + """ + + return self._editable + + @property + def ftype(self) -> FileType: + + """File object type: file or directory + + Returns: + File type + """ + + return self._ftype @property def is_dir(self) -> bool: """Check if the file object is a directory - :return: `True` if the file - is a directory, otherwise `False` - :rtype: bool + Returns: + True if it is a directory, otherwise False """ - if self._ftype == FileType.directory: - return True - return False + return self._ftype == FileType.dir @property def is_file(self) -> bool: """Check if the file object is not a directory - :return: `True` if it is a file, otherwise `False` - :rtype: bool + Returns: + True if it is a file, otherwise False """ - if self._ftype == FileType.file: - return True - return False + return self._ftype == FileType.file @property def size(self) -> float: """File size in bytes - :return: File size - :rtype: float + Returns: + File size """ return self._size diff --git a/python_aternos/atfm.py b/python_aternos/atfm.py index 11f0355..82b311e 100644 --- a/python_aternos/atfm.py +++ b/python_aternos/atfm.py @@ -12,26 +12,32 @@ if TYPE_CHECKING: class FileManager: - """Aternos file manager class for viewing files structure - - :param atserv: :class:`python_aternos.atserver.AternosServer` instance - :type atserv: python_aternos.atserver.AternosServer - """ + """Aternos file manager class + for viewing files structure""" def __init__(self, atserv: 'AternosServer') -> None: + """Aternos file manager class + for viewing files structure + + Args: + atserv (python_aternos.atserver.AternosServer): + atserver.AternosServer instance + """ + self.atserv = atserv - def listdir(self, path: str = '') -> List[AternosFile]: + def list_dir(self, path: str = '') -> List[AternosFile]: """Requests a list of files in the specified directory - :param path: Directory - (an empty string means root), defaults to '' - :type path: str, optional - :return: List of :class:`python_aternos.atfile.AternosFile` - :rtype: List[AternosFile] + Args: + path (str, optional): + Directory (an empty string means root) + + Returns: + List of atfile.AternosFile objects """ path = path.lstrip('/') @@ -42,29 +48,35 @@ class FileManager: filestree = lxml.html.fromstring(filesreq.content) fileslist = filestree.xpath( - '//div[contains(concat(" ",normalize-space(@class)," ")," file ")]' + '//div[@class="file" or @class="file clickable"]' ) files = [] for f in fileslist: ftype_raw = f.xpath('@data-type')[0] - ftype = FileType.file \ - if ftype_raw == 'file' \ - else FileType.directory - fsize = self.extract_size( f.xpath('./div[@class="filesize"]') ) - fullpath = f.xpath('@data-path')[0] - filepath = fullpath[:fullpath.rfind('/')] - filename = fullpath[fullpath.rfind('/'):] + rm_btn = f.xpath('./div[contains(@class,"js-delete-file")]') + dl_btn = f.xpath('./div[contains(@class,"js-download-file")]') + clickable = 'clickable' in f.classes + is_config = ('server.properties' in path) or ('level.dat' in path) + files.append( AternosFile( - self.atserv, - filepath, filename, - ftype, fsize + atserv=self.atserv, + path=f.xpath('@data-path')[0], + + rmable=(len(rm_btn) > 0), + dlable=(len(dl_btn) > 0), + editable=(clickable and not is_config), + + ftype={'file': FileType.file}.get( + ftype_raw, FileType.dir + ), + size=fsize ) ) @@ -74,10 +86,11 @@ class FileManager: """Parses file size from the LXML tree - :param fsize_raw: XPath method result - :type fsize_raw: List[Any] - :return: File size in bytes - :rtype: float + Args: + fsize_raw (List[Any]): XPath parsing result + + Returns: + File size in bytes """ if len(fsize_raw) > 0: @@ -87,7 +100,10 @@ class FileManager: fsize_msr = fsize_text[fsize_text.rfind(' ') + 1:] try: - return self.convert_size(float(fsize_num), fsize_msr) + return self.convert_size( + float(fsize_num), + fsize_msr + ) except ValueError: return -1.0 @@ -100,12 +116,12 @@ class FileManager: """Converts "human" file size to size in bytes - :param num: Size - :type num: Union[int,float] - :param measure: Units (B, kB, MB, GB) - :type measure: str - :return: Size in bytes - :rtype: float + Args: + num (Union[int,float]): Size + measure (str): Units (B, kB, MB, GB) + + Returns: + Size in bytes """ measure_match = { @@ -121,30 +137,35 @@ class FileManager: """Returns :class:`python_aternos.atfile.AternosFile` instance by its path - :param path: Path to file including its filename - :type path: str - :return: _description_ - :rtype: Optional[AternosFile] + Args: + path (str): Path to the file including its filename + + Returns: + atfile.AternosFile object + if file has been found, + otherwise None """ - filepath = path[:path.rfind('/')] + filedir = path[:path.rfind('/')] filename = path[path.rfind('/'):] - filedir = self.listdir(filepath) - for file in filedir: - if file.name == filename: - return file + files = self.list_dir(filedir) - return None + return { + 'file': f + for f in files + if f.name == filename + }.get('file', None) def dl_file(self, path: str) -> bytes: """Returns the file content in bytes (downloads it) - :param path: Path to file including its filename - :type path: str - :return: File content - :rtype: bytes + Args: + path (str): Path to file including its filename + + Returns: + File content """ file = self.atserv.atserver_request( # type: ignore @@ -161,10 +182,11 @@ class FileManager: """Returns the world zip file content by its name (downloads it) - :param world: Name of world, defaults to 'world' - :type world: str, optional - :return: Zip file content - :rtype: bytes + Args: + world (str, optional): Name of world + + Returns: + ZIP file content """ resp = self.atserv.atserver_request( # type: ignore diff --git a/python_aternos/atjsparse.py b/python_aternos/atjsparse.py index a2945be..5b0483c 100644 --- a/python_aternos/atjsparse.py +++ b/python_aternos/atjsparse.py @@ -1,9 +1,6 @@ """Parsing and executing JavaScript code""" import base64 - -from typing import Any - import regex import js2py @@ -13,12 +10,14 @@ arrowexp = regex.compile(r'\w[^\}]*+') def to_ecma5_function(f: str) -> str: - """Converts a ECMA6 function to ECMA5 format (without arrow expressions) + """Converts a ECMA6 function + to ECMA5 format (without arrow expressions) - :param f: ECMA6 function - :type f: str - :return: ECMA5 function - :rtype: str + Args: + f (str): ECMA6 function + + Returns: + ECMA5 function """ f = regex.sub(r'/\*.+?\*/', '', f) @@ -35,23 +34,25 @@ def atob(s: str) -> str: """Decodes base64 string - :param s: Encoded data - :type s: str - :return: Decoded string - :rtype: str + Args: + s (str): Encoded data + + Returns: + Decoded string """ return base64.standard_b64decode(str(s)).decode('utf-8') -def exec_js(f: str) -> Any: +def exec_js(f: str) -> js2py.EvalJs: """Executes a JavaScript function - :param f: ECMA6 function - :type f: str - :return: JavaScript interpreter context - :rtype: Any + Args: + f (str): ECMA6 function + + Returns: + JavaScript interpreter context """ ctx = js2py.EvalJs({'atob': atob}) diff --git a/python_aternos/atplayers.py b/python_aternos/atplayers.py index eb7c69d..eb1272c 100644 --- a/python_aternos/atplayers.py +++ b/python_aternos/atplayers.py @@ -25,26 +25,33 @@ class Lists(enum.Enum): class PlayersList: - """Class for managing operators, whitelist and banned players lists - - :param lst: Players list type, must be - :class:`python_aternos.atplayers.Lists` enum value - :type lst: Union[str,Lists] - :param atserv: :class:`python_aternos.atserver.AternosServer` instance - :type atserv: python_aternos.atserver.AternosServer - """ + """Class for managing operators, + whitelist and banned players lists""" def __init__( self, lst: Union[str, Lists], atserv: 'AternosServer') -> None: + """Class for managing operators, + whitelist and banned players lists + + Args: + lst (Union[str,Lists]): Players list type, must be + atplayers.Lists enum value + atserv (python_aternos.atserver.AternosServer): + atserver.AternosServer instance + """ + self.atserv = atserv self.lst = Lists(lst) + # Fix for #30 issue + # whl_je = whitelist for java + # whl_be = whitelist for bedrock + # whl = common whitelist common_whl = (self.lst == Lists.whl) - # 1 is atserver.Edition.bedrock - bedrock = (atserv.edition == 1) + bedrock = (atserv.is_bedrock) if common_whl and bedrock: self.lst = Lists.whl_be @@ -56,11 +63,12 @@ class PlayersList: """Parse a players list - :param cache: If the function can return - cached list (highly recommended), defaults to True - :type cache: bool, optional - :return: List of players nicknames - :rtype: List[str] + Args: + cache (bool, optional): If the function should + return cached list (highly recommended) + + Returns: + List of players' nicknames """ if cache and self.parsed: @@ -88,8 +96,8 @@ class PlayersList: """Appends a player to the list by the nickname - :param name: Player's nickname - :type name: str + Args: + name (str): Player's nickname """ self.atserv.atserver_request( @@ -106,8 +114,8 @@ class PlayersList: """Removes a player from the list by the nickname - :param name: Player's nickname - :type name: str + Args: + name (str): Player's nickname """ self.atserv.atserver_request( diff --git a/python_aternos/atserver.py b/python_aternos/atserver.py index 8b9fc3f..368ec22 100644 --- a/python_aternos/atserver.py +++ b/python_aternos/atserver.py @@ -3,8 +3,10 @@ import enum import json -from typing import Optional, List -from requests import Response +from typing import Optional +from typing import List, Dict, Any + +import requests from .atconnect import AternosConnect from .aterrors import ServerStartError @@ -17,7 +19,7 @@ from .atwss import AternosWss class Edition(enum.IntEnum): - """Server edition type enum""" + """Server edition type enum (java, bedrock)""" java = 0 bedrock = 1 @@ -32,32 +34,36 @@ class Status(enum.IntEnum): off = 0 on = 1 + starting = 2 shutdown = 3 - unknown = 6 + + loading = 6 error = 7 + + preparing = 10 confirm = 10 class AternosServer: - """Class for controlling your Aternos Minecraft server - - :param servid: Unique server IDentifier - :type servid: str - :param atconn: :class:`python_aternos.atconnect.AternosConnect` - instance with initialized Aternos session - :type atconn: python_aternos.atconnect.AternosConnect - :param reqinfo: Automatically call AternosServer.fetch() - to get all info, defaults to `True` - :type reqinfo: bool, optional - """ + """Class for controlling your Aternos Minecraft server""" def __init__( self, servid: str, atconn: AternosConnect, reqinfo: bool = True) -> None: + """Class for controlling your Aternos Minecraft server + + Args: + servid (str): Unique server IDentifier + atconn (AternosConnect): + AternosConnect instance with initialized Aternos session + reqinfo (bool, optional): Automatically call + `fetch()` to get all info + """ + self.servid = servid self.atconn = atconn if reqinfo: @@ -75,15 +81,17 @@ class AternosServer: def wss(self, autoconfirm: bool = False) -> AternosWss: - """Returns :class:`python_aternos.atwss.AternosWss` - instance for listening server streams in real-time + """Returns AternosWss instance for + listening server streams in real-time - :param autoconfirm: Automatically start server status listener - when AternosWss connects to API to confirm - server launching, defaults to `False` - :type autoconfirm: bool, optional - :return: :class:`python_aternos.atwss.AternosWss` object - :rtype: python_aternos.atwss.AternosWss + Args: + autoconfirm (bool, optional): + Automatically start server status listener + when AternosWss connects to API to confirm + server launching + + Returns: + AternosWss object """ return AternosWss(self, autoconfirm) @@ -95,14 +103,16 @@ class AternosServer: """Starts a server - :param headstart: Start a server in the headstart mode - which allows you to skip all queue, defaults to `False` - :type headstart: bool, optional - :param accepteula: Automatically accept - the Mojang EULA, defaults to `True` - :type accepteula: bool, optional - :raises ServerStartError: When Aternos - is unable to start the server + Args: + headstart (bool, optional): Start a server in + the headstart mode which allows + you to skip all queue + accepteula (bool, optional): + Automatically accept the Mojang EULA + + Raises: + ServerStartError: When Aternos + is unable to start the server """ startreq = self.atserver_request( @@ -171,66 +181,64 @@ class AternosServer: def files(self) -> FileManager: - """Returns :class:`python_aternos.atfm.FileManager` - instance for file operations + """Returns FileManager instance + for file operations - :return: :class:`python_aternos.atfm.FileManager` object - :rtype: python_aternos.atfm.FileManager + Returns: + FileManager object """ return FileManager(self) def config(self) -> AternosConfig: - """Returns :class:`python_aternos.atconf.AternosConfig` - instance for editing server settings + """Returns AternosConfig instance + for editing server settings - :return: :class:`python_aternos.atconf.AternosConfig` object - :rtype: python_aternos.atconf.AternosConfig + Returns: + AternosConfig object """ return AternosConfig(self) def players(self, lst: Lists) -> PlayersList: - """Returns :class:`python_aternos.atplayers.PlayersList` - instance for managing operators, whitelist and banned players lists + """Returns PlayersList instance + for managing operators, whitelist + and banned players lists - :param lst: Players list type, must be - the :class:`python_aternos.atplayers.Lists` enum value - :type lst: python_aternos.atplayers.Lists - :return: :class:`python_aternos.atplayers.PlayersList` - :rtype: python_aternos.atplayers.PlayersList + Args: + lst (Lists): Players list type, + must be the atplayers.Lists enum value + + Returns: + PlayersList object """ return PlayersList(lst, self) def atserver_request( self, url: str, method: str, - params: Optional[dict] = None, - data: Optional[dict] = None, - headers: Optional[dict] = None, - sendtoken: bool = False) -> Response: + params: Optional[Dict[Any, Any]] = None, + data: Optional[Dict[Any, Any]] = None, + headers: Optional[Dict[Any, Any]] = None, + sendtoken: bool = False) -> requests.Response: """Sends a request to Aternos API with server IDenitfier parameter - :param url: Request URL - :type url: str - :param method: Request method, must be GET or POST - :type method: str - :param params: URL parameters, defaults to None - :type params: Optional[dict], optional - :param data: POST request data, if the method is GET, - this dict will be combined with params, defaults to None - :type data: Optional[dict], optional - :param headers: Custom headers, defaults to None - :type headers: Optional[dict], optional - :param sendtoken: If the ajax and SEC token - should be sent, defaults to False - :type sendtoken: bool, optional - :return: API response - :rtype: requests.Response + Args: + url (str): Request URL + method (str): Request method, must be GET or POST + params (Optional[Dict[Any, Any]], optional): URL parameters + data (Optional[Dict[Any, Any]], optional): POST request data, + if the method is GET, this dict + will be combined with params + headers (Optional[Dict[Any, Any]], optional): Custom headers + sendtoken (bool, optional): If the ajax and SEC token should be sent + + Returns: + API response """ return self.atconn.request_cloudflare( @@ -246,10 +254,11 @@ class AternosServer: @property def subdomain(self) -> str: - """Server subdomain (part of domain before `.aternos.me`) + """Server subdomain + (the part of domain before `.aternos.me`) - :return: Subdomain - :rtype: str + Returns: + Subdomain """ atdomain = self.domain @@ -258,10 +267,10 @@ class AternosServer: @subdomain.setter def subdomain(self, value: str) -> None: - """Set new subdomain for your server + """Set a new subdomain for your server - :param value: Subdomain - :type value: str + Args: + value (str): Subdomain """ self.atserver_request( @@ -273,11 +282,12 @@ class AternosServer: @property def motd(self) -> str: - """Server message of the day, - which is shown below its name in the servers list + """Server message of the day + which is shown below its name + in the Minecraft servers list - :return: MOTD - :rtype: str + Returns: + MOTD """ return self._info['motd'] @@ -285,10 +295,10 @@ class AternosServer: @motd.setter def motd(self, value: str) -> None: - """Set new message of the day + """Set a new message of the day - :param value: MOTD - :type value: str + Args: + value (str): New MOTD """ self.atserver_request( @@ -300,10 +310,11 @@ class AternosServer: @property def address(self) -> str: - """Full server address including domain and port + """Full server address + including domain and port - :return: Server address - :rtype: str + Returns: + Server address """ return self._info['displayAddress'] @@ -311,11 +322,11 @@ class AternosServer: @property def domain(self) -> str: - """Server domain (test.aternos.me), - address without port number + """Server domain (e.g. `test.aternos.me`). + In other words, address without port number - :return: Domain - :rtype: str + Returns: + Domain """ return self._info['ip'] @@ -325,8 +336,8 @@ class AternosServer: """Server port number - :return: Port - :rtype: int + Returns: + Port """ return self._info['port'] @@ -336,20 +347,42 @@ class AternosServer: """Server software edition: Java or Bedrock - :return: Software edition - :rtype: Edition + Returns: + Software edition """ soft_type = self._info['bedrock'] return Edition(soft_type) + @property + def is_java(self) -> bool: + + """Check if server software is Java Edition + + Returns: + Is it Minecraft JE + """ + + return not self._info['bedrock'] + + @property + def is_bedrock(self) -> bool: + + """Check if server software is Bedrock Edition + + Returns: + Is it Minefcraft BE + """ + + return bool(self._info['bedrock']) + @property def software(self) -> str: """Server software name (e.g. `Vanilla`) - :return: Software name - :rtype: str + Returns: + Software name """ return self._info['software'] @@ -357,33 +390,50 @@ class AternosServer: @property def version(self) -> str: - """Server software version (e.g. `1.16.5`) + """Server software version (1.16.5) - :return: Software version - :rtype: str + Returns: + Software version """ return self._info['version'] @property - def status(self) -> str: + def css_class(self) -> str: - """Server status string (offline, loading) + """CSS class for + server status block + on official web site + (offline, loading, + loading starting, queueing) - :return: Status string - :rtype: str + Returns: + CSS class """ return self._info['class'] @property - def status_num(self) -> int: + def status(self) -> str: - """Server numeric status. It is highly recommended - to use status string instead of a number. + """Server status string + (offline, loading, preparing) - :return: Status code - :rtype: Status + Returns: + Status string + """ + + return self._info['lang'] + + @property + def status_num(self) -> Status: + + """Server numeric status. + It is highly recommended to use + status string instead of a number + + Returns: + Status code """ return Status(self._info['status']) @@ -391,10 +441,10 @@ class AternosServer: @property def players_list(self) -> List[str]: - """List of connected players nicknames + """List of connected players' nicknames - :return: Connected players - :rtype: List[str] + Returns: + Connected players """ return self._info['playerlist'] @@ -402,10 +452,10 @@ class AternosServer: @property def players_count(self) -> int: - """How many connected players + """How many players are connected - :return: Connected players count - :rtype: int + Returns: + Connected players count """ return int(self._info['players']) @@ -413,10 +463,11 @@ class AternosServer: @property def slots(self) -> int: - """Server slots, how many players can connect + """Server slots, how many + players **can** connect - :return: Slots count - :rtype: int + Returns: + Slots count """ return int(self._info['slots']) @@ -426,8 +477,8 @@ class AternosServer: """Server used RAM in MB - :return: Used RAM - :rtype: int + Returns: + Used RAM """ return int(self._info['ram']) diff --git a/python_aternos/atwss.py b/python_aternos/atwss.py index dcc6b75..d7c342b 100644 --- a/python_aternos/atwss.py +++ b/python_aternos/atwss.py @@ -1,4 +1,4 @@ -"""Connects to Aternos API websocket +"""Connects to Aternos WebSocket API for real-time information""" import enum @@ -35,27 +35,31 @@ class Streams(enum.Enum): none = (-1, None) def __init__(self, num: int, stream: str) -> None: + self.num = num self.stream = stream class AternosWss: - """Class for managing websocket connection - - :param atserv: :class:`python_aternos.atserver.AternosServer` instance - :type atserv: python_aternos.atserver.AternosServer - :param autoconfirm: Automatically start server status listener - when AternosWss connects to API to confirm - server launching, defaults to `False` - :type autoconfirm: bool, optional - """ + """Class for managing websocket connection""" def __init__( self, atserv: 'AternosServer', autoconfirm: bool = False) -> None: + """Class for managing websocket connection + + Args: + atserv (AternosServer): + atserver.AternosServer instance + autoconfirm (bool, optional): + Automatically start server status listener + when AternosWss connects to API to confirm + server launching + """ + self.atserv = atserv self.servid = atserv.servid @@ -73,7 +77,9 @@ class AternosWss: async def confirm(self) -> None: - """Simple way to call AternosServer.confirm from this class""" + """Simple way to call + `AternosServer.confirm` + from this class""" self.atserv.confirm() @@ -86,12 +92,12 @@ class AternosWss: When websocket receives message from the specified stream, it calls all listeners created with this decorator. - :param stream: Stream that your function should listen - :type stream: python_aternos.atwss.Streams - :param args: Arguments which will be passed to your function - :type args: tuple, optional - :return: ... - :rtype: Callable[[Callable[[Any], Coroutine[Any, Any, None]]], Any] + Args: + stream (Streams): Stream that your function should listen + *args (tuple, optional): Arguments which will be passed to your function + + Returns: + ... """ def decorator(func: FunctionT) -> None: @@ -100,7 +106,8 @@ class AternosWss: async def connect(self) -> None: - """Connect to the websocket server and start all stream listeners""" + """Connects to the websocket server + and starts all stream listeners""" headers = [ ('Host', 'aternos.org'), @@ -118,9 +125,14 @@ class AternosWss: ) @self.wssreceiver(Streams.status) - async def confirmfunc(msg): + async def confirmfunc(msg: Dict[str, Any]) -> None: - """Automatically confirm Minecraft server launching""" + """Automatically confirm + Minecraft server launching + + Args: + msg (Dict[str, Any]): Server info dictionary + """ if not self.autoconfirm: return @@ -130,13 +142,14 @@ class AternosWss: confirmation = in_queue and pending if confirmation and not self.confirmed: - self.confirm() + await self.confirm() @self.wssreceiver(Streams.status) - async def streamsfunc(msg): + async def streamsfunc(msg: Dict[str, Any]) -> None: """Automatically starts streams. Detailed description: + https://github.com/DarkCat09/python-aternos/issues/22#issuecomment-1146788496 According to the websocket messages from the web site, Aternos can't receive any data from a stream (e.g. console) until it requests this stream via the special message @@ -148,7 +161,9 @@ class AternosWss: these data is sent from API by default, so there's None value in the second item of its stream type tuple (``). - https://github.com/DarkCat09/python-aternos/issues/22#issuecomment-1146788496 + + Args: + msg (Dict[str, Any]): Server info dictionary """ if msg['status'] == 2: @@ -159,7 +174,7 @@ class AternosWss: continue if strm.stream: - logging.debug(f'Enabling {strm.stream} stream') + logging.debug(f'Requesting {strm.stream} stream') await self.send({ 'stream': strm.stream, 'type': 'start' @@ -180,8 +195,9 @@ class AternosWss: """Sends a message to websocket server - :param obj: Message, may be a string or a dict - :type obj: Union[Dict[str, Any],str] + Args: + obj (Union[Dict[str, Any],str]): + Message, may be a string or a dict """ if isinstance(obj, dict): diff --git a/setup.py b/setup.py index 3ad220f..e2a2d45 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ with open('README.md', 'rt') as readme: setuptools.setup( name='python-aternos', - version='1.1.2', + version='2.0.1', author='Chechkenev Andrey (@DarkCat09)', author_email='aacd0709@mail.ru', description='An unofficial Aternos API', diff --git a/tests/test_js.py b/tests/test_js.py index 890e80a..feaa8b0 100644 --- a/tests/test_js.py +++ b/tests/test_js.py @@ -68,3 +68,7 @@ class TestJs2Py(unittest.TestCase): ctx = atjsparse.exec_js(f) res = ctx.window['AJAX_TOKEN'] self.assertEqual(res, self.results[i]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_login.py b/tests/test_login.py index b588edf..3f6a50c 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -26,5 +26,14 @@ class TestLogin(unittest.TestCase): at = Client.from_hashed( AUTH_USER, AUTH_MD5 ) - srvs = len(at.list_servers()) + + srvs = len( + at.list_servers( + cache=False + ) + ) self.assertTrue(srvs > 0) + + +if __name__ == '__main__': + unittest.main()