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.
This commit is contained in:
DarkCat09 2022-08-26 16:14:07 +04:00
parent dc52f92985
commit 4892430f19
39 changed files with 1832 additions and 569 deletions

3
.gitignore vendored
View file

@ -72,6 +72,9 @@ instance/
# Sphinx documentation
docs/_build/
# MkDocs
site/
# PyBuilder
.pybuilder/
target/

View file

@ -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

2
NOTICE
View file

@ -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.

View file

@ -1,23 +1,77 @@
![Python-Aternos Logo](https://i.ibb.co/3RXcXJ1/aternos-400.png)
***
# Python Aternos
<div align="center">
<img src="https://i.ibb.co/3RXcXJ1/aternos-400.png" alt="Python Aternos Logo">
<h1>
Python Aternos
<div>
<a href="https://pypi.org/project/python-aternos/">
<img src="https://img.shields.io/pypi/v/python-aternos">
</a>
<a href="https://www.apache.org/licenses/LICENSE-2.0.html">
<img src="https://img.shields.io/pypi/l/python-aternos">
</a>
<a href="https://github.com/DarkCat09/python-aternos/commits">
<img src="https://img.shields.io/github/last-commit/DarkCat09/python-aternos">
</a>
<a href="https://github.com/DarkCat09/python-aternos/issues">
<img src="https://img.shields.io/github/issues/DarkCat09/python-aternos">
</a>
</div>
</h1>
</div>
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,16 +101,20 @@ 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 |
@ -66,17 +124,20 @@ The documentation have not made yet. View examples and ask in the issues.
|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.

View file

@ -1 +0,0 @@
Very interesting information

165
docs/howto/auth.md Normal file
View file

@ -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
[<python_aternos.atserver.AternosServer object at 0x7f97bd8b5690>]
```
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)
```

1
docs/howto/config.md Normal file
View file

@ -0,0 +1 @@
## Coming soon

1
docs/howto/discord.md Normal file
View file

@ -0,0 +1 @@
## Coming soon

233
docs/howto/files.md Normal file
View file

@ -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('/')
[<python_aternos.atfile.AternosFile object at 0x7f1b0...>, ...]
```
## Get file by its path
```python
>>> myfile = fm.get_file('/server.properties')
<python_aternos.atfile.AternosFile object at 0x7f1b0...>
```
## 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)
```

57
docs/howto/players.md Normal file
View file

@ -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_']
```

219
docs/howto/server.md Normal file
View file

@ -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.

1
docs/howto/websocket.md Normal file
View file

@ -0,0 +1 @@
## Coming soon

View file

@ -1,4 +1,153 @@
![Python-Aternos Logo](https://i.ibb.co/3RXcXJ1/aternos-400.png)
# Python Aternos
<div align="center">
<img src="https://i.ibb.co/3RXcXJ1/aternos-400.png" alt="Python Aternos Logo">
<h1>
Python Aternos
<div>
<a href="https://pypi.org/project/python-aternos/">
<img src="https://img.shields.io/pypi/v/python-aternos">
</a>
<a href="https://www.apache.org/licenses/LICENSE-2.0.html">
<img src="https://img.shields.io/pypi/l/python-aternos">
</a>
<a href="https://github.com/DarkCat09/python-aternos/commits">
<img src="https://img.shields.io/github/last-commit/DarkCat09/python-aternos">
</a>
<a href="https://github.com/DarkCat09/python-aternos/issues">
<img src="https://img.shields.io/github/issues/DarkCat09/python-aternos">
</a>
</div>
</h1>
</div>
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.
```

View file

@ -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

View file

@ -0,0 +1,2 @@
## `atclient` (Entry point)
### ::: python_aternos.atclient

2
docs/reference/atconf.md Normal file
View file

@ -0,0 +1,2 @@
## atconf
### ::: python_aternos.atconf

View file

@ -0,0 +1,2 @@
## atconnect
### ::: python_aternos.atconnect

View file

@ -0,0 +1,2 @@
## aterrors
### ::: python_aternos.aterrors

2
docs/reference/atfile.md Normal file
View file

@ -0,0 +1,2 @@
## atfile
### ::: python_aternos.atfile

2
docs/reference/atfm.md Normal file
View file

@ -0,0 +1,2 @@
## atfm
### ::: python_aternos.atfm

View file

@ -0,0 +1,2 @@
## atjsparse
### ::: python_aternos.atjsparse

View file

@ -0,0 +1,2 @@
## `atplayers`
### ::: python_aternos.atplayers

View file

@ -0,0 +1,2 @@
## `atserver`
### ::: python_aternos.atserver

2
docs/reference/atwss.md Normal file
View file

@ -0,0 +1,2 @@
## atwss
### ::: python_aternos.atwss

View file

@ -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:

View file

@ -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'

View file

@ -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
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),
defaults to True
:type incl_servers: bool, optional
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={

View file

@ -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:

View file

@ -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 <head> 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 `<head>` 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', ''
)

View file

@ -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)
)
)

View file

@ -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

View file

@ -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

View file

@ -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})

View file

@ -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(

View file

@ -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
Args:
autoconfirm (bool, optional):
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
server launching
Returns:
AternosWss object
"""
return AternosWss(self, autoconfirm)
@ -95,13 +103,15 @@ 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
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
"""
@ -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'])

View file

@ -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
(`<Streams.status: (0, None)>`).
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):

View file

@ -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',

View file

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

View file

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