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
+
+
+
+
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
+
+
+
+
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()