Finally fixed 400 by updating URLs, improved logging

This commit is contained in:
DarkCat09 2023-05-29 11:44:19 +04:00
parent 8ae655a34e
commit 4baf4ea1a7
17 changed files with 174 additions and 267 deletions

View file

@ -1,74 +0,0 @@
aternos.org/software/v/**TYPE**/**MCVER**-latest|recommended|**SOFTVER**
(AternosSoftware) //div[@class="version-title"]
(software_name) /h1[@class="version-title-name"]
(software_id) /div[@id="install-software"]/@data-software
GET software/install.php
software: rLyATopqZP79WHHR
reinstall: 0 OR 1
GET confirm.php
GET config.php
file: /server.properties OR /world/level.dat
option: max-players OR resource-pack OR Data:hardcore OR Data:GameRules:commandBlockOutput
value: 20
GET timezone.php
timezone: Europe/Ulyanovsk
GET image.php
image: openjdk:8
GET mclogs.php
(save log to mclo.gs)
response.json().id
https://api.mclo.gs/1/raw/**ID**
POST create.php
file: /config/hello
type: directory OR file
POST delete.php
file: /config/123.txt
POST save.php
file: /config/123.txt
content: ... (x-www-form-urlencoded; charset=UTF-8)
GET files/download.php?file=**FILENAME_ABSOLUTE**
(ex. file=/world will download in ZIP all directory)
GET worlds/download.php?world=**WORLD_NAME**
GET players/add.php,remove.php
list: whitelist,ops,banned-players,banned-ips
name: CodePicker13 *OR* 1.2.3.4(in case of IP)
(list players) //div[@class="page-content page-players"]/div[@class="player-list"]/div[@class="list-item-container"]
(players[...]) ./div[@class="list-item"]/div[@class="list-name"] (and class="list-avatar")
POST friends/create.php
username: t3test
(LISTUSERIDs) //div[@class="friends-share-list list-players"]/div[@class="list-item-container"]/@data-id
POST friends/delete.php
id: **LISTUSERID**
POST friends/update.php
id: **LISTUSERID**
permissions: json(permissions)
GET driveBackup/autoBackups.php?enabled=**0or1**&amount=**AUTOBACKUPS_COUNT_LIMIT**
(list backups) //div[@class="backups"]/div[@class="file"]
(backups[...]) ./@id, re.search(r'backup-(\w+)', _)[1]
(backups[...]) ./div[@class="filename"] (/span[@class="backup-time js-date-time"], then /@data-date or content)
(backups[...]) ./div[@class="backup-user,filesize"]
POST driveBackup/create.php
name: MyBackup2
POST driveBackup/restore.php,delete.php
backupID: 5
GET /panel/img/skin.php?name=**NICKNAME**
(get player's head in png)

View file

@ -1,7 +1,5 @@
from getpass import getpass
from typing import Optional
from python_aternos import Client
from python_aternos.atfile import AternosFile
user = input('Username: ')
pswd = getpass('Password: ')

View file

@ -11,7 +11,10 @@ atclient.login(user, pswd)
srvs = aternos.list_servers()
for srv in srvs:
print('***', srv.domain, '***')
print()
print('***', srv.servid, '***')
srv.fetch()
print(srv.domain)
print(srv.motd)
print('*** Status:', srv.status)
print('*** Full address:', srv.address)
@ -20,3 +23,5 @@ for srv in srvs:
print('*** Minecraft:', srv.software, srv.version)
print('*** IsBedrock:', srv.edition == atserver.Edition.bedrock)
print('*** IsJava:', srv.edition == atserver.Edition.java)
print()

View file

@ -1,23 +1,25 @@
import asyncio
import logging
from getpass import getpass
from typing import Tuple, Dict, Any
from python_aternos import Client, Streams
# Request credentials
user = input('Username: ')
pswd = getpass('Password: ')
# Debug logging
logs = input('Show detailed logs? (y/n) ').strip().lower() == 'y'
if logs:
logging.basicConfig(level=logging.DEBUG)
# Authentication
# Instantiate Client
atclient = Client()
aternos = atclient.account
# Enable debug logging
logs = input('Show detailed logs? (y/n) ').strip().lower() == 'y'
if logs:
atclient.debug = True
# Authenticate
atclient.login(user, pswd)
server = aternos.list_servers()[0]

View file

@ -1,23 +1,25 @@
import asyncio
import logging
from getpass import getpass
from typing import Tuple, Dict, Any
from python_aternos import Client, Streams
# Request credentials
user = input('Username: ')
pswd = getpass('Password: ')
# Debug logging
logs = input('Show detailed logs? (y/n) ').strip().lower() == 'y'
if logs:
logging.basicConfig(level=logging.DEBUG)
# Authentication
# Instantiate Client
atclient = Client()
aternos = atclient.account
# Enable debug logging
logs = input('Show detailed logs? (y/n) ').strip().lower() == 'y'
if logs:
atclient.debug = True
# Authenticate
atclient.login(user, pswd)
server = aternos.list_servers()[0]

View file

@ -74,9 +74,9 @@ max-args=10
max-attributes=10
max-bool-expr=5
max-branches=12
max-locals=16
max-locals=20
max-parents=7
max-public-methods=30
max-public-methods=31
max-returns=6
max-statements=50
min-public-methods=2

View file

@ -1,52 +1,11 @@
"""
Unofficial Aternos API module written in Python.
It uses Aternos' private API and html parsing"""
"""Init"""
from .atclient import Client
from .atserver import AternosServer
from .atserver import Edition
from .atserver import Status
from .atconnect import AternosConnect
from .atplayers import PlayersList
from .atplayers import Lists
from .atconf import AternosConfig
from .atconf import ServerOpts
from .atconf import WorldOpts
from .atconf import WorldRules
from .atconf import Gamemode
from .atconf import Difficulty
from .atwss import AternosWss
from .atwss import Streams
from .atfm import FileManager
from .atfile import AternosFile
from .atfile import FileType
from .aterrors import AternosError
from .aterrors import CloudflareError
from .aterrors import CredentialsError
from .aterrors import TokenError
from .aterrors import ServerError
from .aterrors import ServerStartError
from .aterrors import FileError
from .aterrors import AternosPermissionError
from .atjsparse import Js2PyInterpreter
from .atjsparse import NodeInterpreter
__all__ = [
'atclient', 'atserver', 'atconnect',
'atplayers', 'atconf', 'atwss',
'atfm', 'atfile',
'aterrors', 'atjsparse',
'Client', 'AternosServer', 'AternosConnect',
'PlayersList', 'AternosConfig', 'AternosWss',
'FileManager', 'AternosFile', 'AternosError',
'CloudflareError', 'CredentialsError', 'TokenError',
'ServerError', 'ServerStartError', 'FileError',
'AternosPermissionError',
'Js2PyInterpreter', 'NodeInterpreter',
'Edition', 'Status', 'Lists',
'ServerOpts', 'WorldOpts', 'WorldRules',
'Gamemode', 'Difficulty', 'Streams', 'FileType',
]

View file

@ -21,6 +21,7 @@ if TYPE_CHECKING:
from .atclient import Client
ACCOUNT_URL = f'{AJAX_URL}/account'
email_re = re.compile(
r'^[A-Za-z0-9\-_+.]+@[A-Za-z0-9\-_+.]+\.[A-Za-z0-9\-]+$|^$'
)
@ -116,7 +117,7 @@ class AternosAccount:
"""
self.atconn.request_cloudflare(
f'{AJAX_URL}/account/username.php',
f'{ACCOUNT_URL}/username',
'POST', data={'username': value},
sendtoken=True,
)
@ -136,7 +137,7 @@ class AternosAccount:
raise ValueError('Invalid e-mail')
self.atconn.request_cloudflare(
f'{AJAX_URL}/account/email.php',
f'{ACCOUNT_URL}/email',
'POST', data={'email': value},
sendtoken=True,
)
@ -165,7 +166,7 @@ class AternosAccount:
"""
self.atconn.request_cloudflare(
f'{AJAX_URL}/account/password.php',
f'{ACCOUNT_URL}/password',
'POST', data={
'oldpassword': old,
'newpassword': new,
@ -178,7 +179,7 @@ class AternosAccount:
a QR code for enabling 2FA"""
return self.atconn.request_cloudflare(
f'{AJAX_URL}/account/secret.php',
f'{ACCOUNT_URL}/secret',
'GET', sendtoken=True,
).json()
@ -205,7 +206,7 @@ class AternosAccount:
"""
self.atconn.request_cloudflare(
f'{AJAX_URL}/account/twofactor.php',
f'{ACCOUNT_URL}/twofactor',
'POST', data={'code': code},
sendtoken=True,
)
@ -218,7 +219,7 @@ class AternosAccount:
"""
self.atconn.request_cloudflare(
f'{AJAX_URL}/account/disbaleTwofactor.php',
f'{ACCOUNT_URL}/disbaleTwofactor',
'POST', data={'code': code},
sendtoken=True,
)

View file

@ -5,7 +5,7 @@ import os
import re
from typing import Optional, Type
from .atlog import log
from .atlog import log, is_debug, set_debug
from .atmd5 import md5encode
from .ataccount import AternosAccount
@ -28,12 +28,11 @@ class Client:
def __init__(self) -> None:
# Config
self.debug = False
self.sessions_dir = '~'
self.js: Type[Interpreter] = Js2PyInterpreter
# ###
self.saved_session = ''
self.saved_session = '~/.aternos' # will be rewritten by login()
self.atconn = AternosConnect()
self.account = AternosAccount(self)
@ -101,7 +100,7 @@ class Client:
credentials['code'] = str(code)
loginreq = self.atconn.request_cloudflare(
f'{AJAX_URL}/account/login.php',
f'{AJAX_URL}/account/login',
'POST', data=credentials, sendtoken=True,
)
@ -123,18 +122,18 @@ class Client:
"""Log out from the Aternos account"""
self.atconn.request_cloudflare(
f'{AJAX_URL}/account/logout.php',
f'{AJAX_URL}/account/logout',
'GET', sendtoken=True,
)
self.remove_session(self.saved_session)
def restore_session(self, filename: str = '~/.aternos') -> None:
def restore_session(self, file: str = '~/.aternos') -> None:
"""Restores ATERNOS_SESSION cookie and,
if included, servers list, from a session file
Args:
filename (str, optional): Filename
file (str, optional): Filename
Raises:
FileNotFoundError: If the file cannot be found
@ -142,13 +141,13 @@ class Client:
(or the file at all) has incorrect format
"""
filename = os.path.expanduser(filename)
log.debug('Restoring session from %s', filename)
file = os.path.expanduser(file)
log.debug('Restoring session from %s', file)
if not os.path.exists(filename):
if not os.path.exists(file):
raise FileNotFoundError()
with open(filename, 'rt', encoding='utf-8') as f:
with open(file, 'rt', encoding='utf-8') as f:
saved = f.read() \
.strip() \
.replace('\r\n', '\n') \
@ -164,7 +163,7 @@ class Client:
self.account.refresh_servers(saved[1:])
self.atconn.session.cookies['ATERNOS_SESSION'] = session
self.saved_session = filename
self.saved_session = file
def save_session(
self,
@ -231,3 +230,11 @@ class Client:
)
return f'{sessions_dir}/.at_{secure}'
@property
def debug(self) -> bool:
return is_debug()
@debug.setter
def debug(self, state: bool) -> None:
return set_debug(state)

View file

@ -1,6 +1,5 @@
"""Stores API session and sends requests"""
import logging
import re
import time
@ -16,7 +15,7 @@ import requests
from cloudscraper import CloudScraper
from .atlog import log
from .atlog import log, is_debug
from . import atjsparse
from .aterrors import TokenError
@ -163,7 +162,8 @@ class AternosConnect:
headers: Optional[Dict[Any, Any]] = None,
reqcookies: Optional[Dict[Any, Any]] = None,
sendtoken: bool = False,
retry: int = 5) -> requests.Response:
retries: int = 5,
timeout: int = 4) -> requests.Response:
"""Sends a request to Aternos API bypass Cloudflare
Args:
@ -177,8 +177,9 @@ class AternosConnect:
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
retries (int, optional): How many times parser must retry
connection to API bypass Cloudflare
timeout (int, optional): Request timeout in seconds
Raises:
CloudflareError: When the parser has exceeded retries count
@ -188,7 +189,7 @@ class AternosConnect:
API response
"""
if retry <= 0:
if retries <= 0:
raise CloudflareError('Unable to bypass Cloudflare protection')
try:
@ -217,7 +218,7 @@ class AternosConnect:
reqcookies['ATERNOS_SESSION'] = self.atcookie
del self.session.cookies['ATERNOS_SESSION']
if log.level == logging.DEBUG:
if is_debug():
reqcookies_dbg = {
k: str(v or '')[:3]
@ -240,18 +241,19 @@ class AternosConnect:
sendreq = partial(
self.session.post,
params=params,
data=data
data=data,
)
else:
sendreq = partial(
self.session.get,
params={**params, **data}
params={**params, **data},
)
req = sendreq(
url,
headers=headers,
cookies=reqcookies
cookies=reqcookies,
timeout=timeout,
)
resp_type = req.headers.get('content-type', '')
@ -265,7 +267,7 @@ class AternosConnect:
url, method,
params, data,
headers, reqcookies,
sendtoken, retry - 1
sendtoken, retries - 1
)
log.debug('AternosConnect received: %s', req.text[:65])

View file

@ -84,6 +84,7 @@ class NodeInterpreter(Interpreter):
server_js = file_dir / 'data' / 'server.js'
self.url = f'http://{host}:{port}'
self.timeout = 2
# pylint: disable=consider-using-with
self.proc = subprocess.Popen(
@ -100,11 +101,11 @@ class NodeInterpreter(Interpreter):
log.debug('Received from server.js: %s', ok_msg)
def exec_js(self, func: str) -> None:
resp = requests.post(self.url, data=func)
resp = requests.post(self.url, data=func, timeout=self.timeout)
resp.raise_for_status()
def get_var(self, name: str) -> Any:
resp = requests.post(self.url, data=name)
resp = requests.post(self.url, data=name, timeout=self.timeout)
resp.raise_for_status()
log.debug('NodeJS response: %s', resp.content)
return json.loads(resp.content)

View file

@ -1,4 +1,31 @@
"""Creates a logger"""
import logging
log = logging.getLogger('aternos')
handler = logging.StreamHandler()
fmt = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
handler.setFormatter(fmt)
log.addHandler(handler)
def is_debug() -> bool:
"""Is debug logging enabled"""
return log.level == logging.DEBUG
def set_debug(state: bool) -> None:
"""Enable debug logging"""
if state:
set_level(logging.DEBUG)
else:
set_level(logging.WARNING)
def set_level(level: int) -> None:
log.setLevel(level)
handler.setLevel(level)

View file

@ -4,11 +4,8 @@ import re
import json
import enum
from typing import Optional
from typing import List, Dict, Any
import requests
from typing import List
from functools import partial
from .atconnect import BASE_URL, AJAX_URL
from .atconnect import AternosConnect
@ -24,6 +21,7 @@ from .aterrors import AternosError
from .aterrors import ServerStartError
SERVER_URL = f'{AJAX_URL}/server'
status_re = re.compile(
r'<script>\s*var lastStatus\s*?=\s*?(\{.+?\});?\s*<\/script>'
)
@ -58,27 +56,35 @@ class Status(enum.IntEnum):
class AternosServer:
"""Class for controlling your Aternos Minecraft server"""
def __init__(
self, servid: str,
atconn: AternosConnect,
reqinfo: bool = False) -> None:
autofetch: bool = False) -> 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
autofetch (bool, optional): Automatically call
`fetch()` to get all info
"""
self.servid = servid
self.atconn = atconn
if reqinfo:
self._info = {}
self.atserver_request = partial(
self.atconn.request_cloudflare,
reqcookies={
'ATERNOS_SERVER': self.servid,
}
)
if autofetch:
self.fetch()
def fetch(self) -> None:
@ -113,6 +119,7 @@ class AternosServer:
def start(
self,
headstart: bool = False,
access_credits: bool = False,
accepteula: bool = True) -> None:
"""Starts a server
@ -120,6 +127,9 @@ class AternosServer:
headstart (bool, optional): Start a server in
the headstart mode which allows
you to skip all queue
access_credits (bool, optional):
Some new parameter in Aternos API,
I don't know what it is
accepteula (bool, optional):
Automatically accept the Mojang EULA
@ -129,9 +139,12 @@ class AternosServer:
"""
startreq = self.atserver_request(
f'{AJAX_URL}/start.php',
'GET', params={'headstart': int(headstart)},
sendtoken=True
f'{SERVER_URL}/start',
'GET', params={
'headstart': int(headstart),
'access-credits': int(access_credits),
},
sendtoken=True,
)
startresult = startreq.json()
@ -151,40 +164,40 @@ class AternosServer:
"""Confirms server launching"""
self.atserver_request(
f'{AJAX_URL}/confirm.php',
'GET', sendtoken=True
f'{SERVER_URL}/confirm',
'GET', sendtoken=True,
)
def stop(self) -> None:
"""Stops the server"""
self.atserver_request(
f'{AJAX_URL}/stop.php',
'GET', sendtoken=True
f'{SERVER_URL}/stop',
'GET', sendtoken=True,
)
def cancel(self) -> None:
"""Cancels server launching"""
self.atserver_request(
f'{AJAX_URL}/cancel.php',
'GET', sendtoken=True
f'{SERVER_URL}/cancel',
'GET', sendtoken=True,
)
def restart(self) -> None:
"""Restarts the server"""
self.atserver_request(
f'{AJAX_URL}/restart.php',
'GET', sendtoken=True
f'{SERVER_URL}/restart',
'GET', sendtoken=True,
)
def eula(self) -> None:
"""Accepts the Mojang EULA"""
"""Sends a request to accept the Mojang EULA"""
self.atserver_request(
f'{AJAX_URL}/eula.php',
'GET', sendtoken=True
f'{SERVER_URL}/accept-eula',
'GET', sendtoken=True,
)
def files(self) -> FileManager:
@ -222,43 +235,40 @@ class AternosServer:
return PlayersList(lst, self)
def atserver_request(
self, url: str, method: str,
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
def set_subdomain(self, value: str) -> None:
"""Set a new subdomain for your server
(the part before `.aternos.me`)
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
value (str): Subdomain
"""
return self.atconn.request_cloudflare(
url=url, method=method,
params=params, data=data,
headers=headers,
reqcookies={
'ATERNOS_SERVER': self.servid
},
sendtoken=sendtoken
self.atserver_request(
f'{SERVER_URL}/options/set-subdomain',
'GET', params={'subdomain': value},
sendtoken=True,
)
def set_motd(self, value: str) -> None:
"""Set new Message of the Day
(shown below the name in the Minecraft servers list).
Formatting with "paragraph sign + code" is supported,
see https://minecraft.tools/color-code.php
Args:
value (str): MOTD
"""
self.atserver_request(
f'{SERVER_URL}/options/set-motd',
'POST', data={'motd': value},
sendtoken=True,
)
@property
def subdomain(self) -> str:
"""Server subdomain
(the part of domain before `.aternos.me`)
"""Get the server subdomain
(the part before `.aternos.me`)
Returns:
Subdomain
@ -267,25 +277,10 @@ class AternosServer:
atdomain = self.domain
return atdomain[:atdomain.find('.')]
@subdomain.setter
def subdomain(self, value: str) -> None:
"""Set a new subdomain for your server
Args:
value (str): Subdomain
"""
self.atserver_request(
f'{AJAX_URL}/options/subdomain.php',
'GET', params={'subdomain': value},
sendtoken=True
)
@property
def motd(self) -> str:
"""Server message of the day
which is shown below its name
in the Minecraft servers list
"""Get the server message of the day
(shown below its name in Minecraft servers list)
Returns:
MOTD
@ -293,20 +288,6 @@ class AternosServer:
return self._info['motd']
@motd.setter
def motd(self, value: str) -> None:
"""Set a new message of the day
Args:
value (str): New MOTD
"""
self.atserver_request(
f'{AJAX_URL}/options/motd.php',
'POST', data={'motd': value},
sendtoken=True
)
@property
def address(self) -> str:
"""Full server address
@ -392,11 +373,11 @@ class AternosServer:
@property
def css_class(self) -> str:
"""CSS class for
server status block
on official web site
(offline, loading,
loading starting, queueing)
"""CSS class for the server status element
on official web site: offline, online, loading, etc.
See https://aternos.dc09.ru/howto/server/#server-info
In most cases you need `AternosServer.status` instead of this
Returns:
CSS class

View file

@ -4,6 +4,7 @@
"requires": true,
"packages": {
"": {
"name": "data",
"dependencies": {
"vm2": "^3.9.13"
}
@ -28,9 +29,9 @@
}
},
"node_modules/vm2": {
"version": "3.9.13",
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.13.tgz",
"integrity": "sha512-0rvxpB8P8Shm4wX2EKOiMp7H2zq+HUE/UwodY0pCZXs9IffIKZq6vUti5OgkVCTakKo9e/fgO4X1fkwfjWxE3Q==",
"version": "3.9.19",
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.19.tgz",
"integrity": "sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==",
"dependencies": {
"acorn": "^8.7.0",
"acorn-walk": "^8.2.0"
@ -55,9 +56,9 @@
"integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA=="
},
"vm2": {
"version": "3.9.13",
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.13.tgz",
"integrity": "sha512-0rvxpB8P8Shm4wX2EKOiMp7H2zq+HUE/UwodY0pCZXs9IffIKZq6vUti5OgkVCTakKo9e/fgO4X1fkwfjWxE3Q==",
"version": "3.9.19",
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.19.tgz",
"integrity": "sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==",
"requires": {
"acorn": "^8.7.0",
"acorn-walk": "^8.2.0"

View file

@ -18,17 +18,12 @@ with mock:
)
mock.get(
f'{BASE_URL}/server/',
f'{BASE_URL}/server',
content=files.read_html('aternos_server1'),
)
mock.get(
f'{AJAX_URL}/status.php',
content=files.read_html('aternos_status'),
)
mock.post(
f'{AJAX_URL}/account/login.php',
f'{AJAX_URL}/account/login',
json={
'success': True,
'error': None,

View file

@ -1 +0,0 @@
{"brand":"aternos","status":0,"change":1667483582,"slots":20,"problems":0,"players":0,"playerlist":[],"message":{"text":"","class":"blue"},"dynip":null,"bedrock":false,"host":"","port":18713,"headstarts":null,"ram":0,"lang":"offline","label":"Offline","class":"offline","countdown":null,"queue":null,"id":"S0m3DGvTlbv8FfIM","name":"world35v","software":"Vanilla","softwareId":"NJcwtD9vj2X7udfa","type":"vanilla","version":"1.19.3","deprecated":false,"ip":"world35v.aternos.me","displayAddress":"world35v.aternos.me","motd":"\u00a77\u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c \u043d\u0430 \u0441\u0435\u0440\u0432\u0435\u0440 \u0438\u0433\u0440\u043e\u043a\u0430 \u00a79world35v\u00a77!","onlineMode":true,"icon":"fa-stop-circle","dns":{"type":"DEFAULT","domains":["world35v.aternos.me"],"host":null,"port":null}}

View file

@ -25,6 +25,7 @@ class TestHttp(unittest.TestCase):
at = Client()
at.login('test', '')
srv = at.account.list_servers(cache=False)[0]
srv.fetch()
self.assertEqual(
srv.subdomain,
'world35v',