Bugfixes in atfile,wss,connect

This commit is contained in:
DarkCat09 2022-03-25 16:45:38 +04:00
parent 48f8090d2a
commit cec2938804
10 changed files with 234 additions and 229 deletions

View file

@ -28,7 +28,7 @@ class Client:
loginreq = atconn.request_cloudflare(
f'https://aternos.org/panel/ajax/account/login.php',
atconnect.REQPOST, data=credentials,
'POST', data=credentials,
sendtoken=True
)
@ -51,7 +51,7 @@ class Client:
def from_session(cls, session:str):
atconn = AternosConnect()
atconn.session.cookies.set('ATERNOS_SESSION', session)
atconn.session.cookies['ATERNOS_SESSION'] = session
atconn.parse_token()
atconn.generate_sec()
@ -63,14 +63,13 @@ class Client:
atconn = AternosConnect()
auth = atconn.request_cloudflare(
'https://aternos.org/auth/google-login',
atconnect.REQGET, redirect=False
'GET', redirect=False
)
return auth.headers['Location']
def list_servers(self) -> List[atserver.AternosServer]:
serverspage = self.atconn.request_cloudflare(
'https://aternos.org/servers/',
atconnect.REQGET
'https://aternos.org/servers/', 'GET'
)
serverstree = lxml.html.fromstring(serverspage.content)
serverslist = serverstree.xpath('//div[contains(@class,"servers ")]/div')

View file

@ -4,11 +4,11 @@ import lxml.html
from typing import Any, Dict, List, Optional
from typing import TYPE_CHECKING
from . import atconnect
if TYPE_CHECKING:
from .atserver import AternosServer
#
# server.options
class ServerOpts(enum.Enum):
players = 'max-players'
gm = 'gamemode'
@ -25,16 +25,20 @@ class ServerOpts(enum.Enum):
forcegm = 'force-gamemode'
spawnlock = 'spawn-protection'
cmds = 'allow-cheats'
packreq = 'require-resource-pack'
pack = 'resource-pack'
DAT_PREFIX = 'Data:'
DAT_GR_PREFIX = 'Data:GameRules:'
# level.dat
class WorldOpts(enum.Enum):
seed = 'randomseed'
seed12 = 'randomseed'
seed = 'seed'
hardcore = 'hardcore'
difficulty = 'difficulty'
difficulty = 'Difficulty'
# /gamerule
class WorldRules(enum.Enum):
advs = 'announceAdvancements'
univanger = 'universalAnger'
@ -56,6 +60,7 @@ class WorldRules(enum.Enum):
drowndmg = 'drowningDamage'
falldmg = 'fallDamage'
firedmg = 'fireDamage'
snowdmg = 'freezeDamage'
forgive = 'forgiveDeadPlayers'
keepinv = 'keepInventory'
deathmsg = 'showDeathMessages'
@ -64,7 +69,8 @@ class WorldRules(enum.Enum):
entcram = 'maxEntityCramming'
mobgrief = 'mobGriefing'
regen = 'naturalRegeneration'
rndtick = 'randomTickspeed'
sleeppct = 'playersSleepingPercentage'
rndtick = 'randomTickSpeed'
spawnradius = 'spawnRadius'
reducedf3 = 'reducedDebugInfo'
spectchunkgen = 'spectatorsGenerateChunks'
@ -85,24 +91,43 @@ class Difficulty(enum.IntEnum):
normal = 2
hard = 3
JDK = 'openjdk:{}'
OJ9 = 'adoptopenjdk:{}-jre-openj9-bionic'
#
# jre types for set_java
javatype = {
'jdk': 'openjdk:{ver}',
'openj9-1': 'adoptopenjdk:{ver}-jre-openj9-bionic',
'openj9-2': 'ibm-semeru-runtimes:open-{ver}-jre'
}
# checking java version format
javacheck = re.compile(
''.join(
list(
map(
# create a regexp for each jre type,
# e.g.: (^openjdk:\d+$)|
lambda i: '(^' + javatype[i].format(ver=r'\d+') + '$)|',
javatype
)
)
).rstrip('|')
)
# checking timezone format
tzcheck = re.compile(r'(^[A-Z]\w+\/[A-Z]\w+$)|^UTC$')
# options types converting
convert = {
'config-option-number': int,
'config-option-select': int,
'config-option-toggle': bool
}
FLAG_PROP_TYPE = 1
# MAIN CLASS
class AternosConfig:
def __init__(self, atserv:'AternosServer') -> None:
self.atserv = atserv
@property
def timezone(self) -> str:
def get_timezone(self) -> str:
optreq = self.atserv.atserver_request(
'https://aternos.org/options', 'GET'
@ -113,12 +138,11 @@ class AternosConfig:
tztext = tzopt.xpath('.//div[@class="option current"]')[0].text
return tztext.strip()
@timezone.setter
def timezone(self, value:str) -> None:
def set_timezone(self, value:str) -> None:
matches_tz = re.search(r'(?:^[A-Z]\w+\/[A-Z]\w+$)|^UTC$', value)
if matches_tz == None:
raise AttributeError('Timezone must match zoneinfo format: Area/Location')
matches_tz = tzcheck.search(value)
if not matches_tz:
raise ValueError('Timezone must match zoneinfo format: Area/Location')
self.atserv.atserver_request(
'https://aternos.org/panel/ajax/timezone.php',
@ -126,24 +150,21 @@ class AternosConfig:
sendtoken=True
)
@property
def java_version(self) -> str:
def get_java(self) -> str:
optreq = self.atserv.atserver_request(
'https://aternos.org/options',
'GET'
'https://aternos.org/options', 'GET'
)
opttree = lxml.html.fromstring(optreq)
imgopt = opttree.xpath('//div[@class="options-other-input image-switch"]')[0]
imgver = imgopt.xpath('.//div[@class="option current"]/@data-value')[0]
return imgver
@java_version.setter
def java_version(self, value:str) -> None:
matches_jdkver = re.search(r'^(?:adopt)?openjdk:(\d+)(?:-jre-openj9-bionic)?$', value)
if matches_jdkver == None:
raise AttributeError('Incorrect Java image version format!')
def set_java(self, value:str) -> None:
matches_jdkver = javacheck.search(value)
if not matches_jdkver:
raise ValueError('Incorrect Java image version format!')
self.atserv.atserver_request(
'https://aternos.org/panel/ajax/image.php',
@ -160,8 +181,8 @@ class AternosConfig:
option, value
)
def get_server_props(self, flags:int=FLAG_PROP_TYPE) -> Dict[str,Any]:
return self.__get_all_props('https://aternos.org/options', flags)
def get_server_props(self, proptyping:bool=True) -> Dict[str,Any]:
return self.__get_all_props('https://aternos.org/options', proptyping)
def set_server_props(self, props:Dict[str,Any]) -> None:
for key in props:
@ -186,11 +207,11 @@ class AternosConfig:
def get_world_props(
self, world:str='world',
flags:int=FLAG_PROP_TYPE) -> Dict[str,Any]:
proptyping:bool=True) -> Dict[str,Any]:
self.__get_all_props(
f'https://aternos.org/files/{world}/level.dat',
flags, [DAT_PREFIX, DAT_GR_PREFIX]
proptyping, [DAT_PREFIX, DAT_GR_PREFIX]
)
def set_world_props(self, props:Dict[str,Any]) -> None:
@ -212,37 +233,28 @@ class AternosConfig:
)
def __get_all_props(
self, url:str, flags:int=FLAG_PROP_TYPE,
self, url:str, proptyping:bool=True,
prefixes:Optional[List[str]]=None) -> Dict[str,Any]:
optreq = self.atserv.atserver_request(
url,
'GET'
)
optreq = self.atserv.atserver_request(url, 'GET')
opttree = lxml.html.fromstring(optreq.content)
configs = opttree.xpath('//div[@class="config-options"]')
for i in range(len(configs)):
conf = configs[i]
for i, conf in enumerate(configs):
opts = conf.xpath('/div[contains(@class,"config-option ")]')
result = {}
for opt in opts:
key = opt.xpath('.//span[@class="config-option-output-key"]')[0].text
value = opt.xpath('.//span[@class="config-option-output-value"]')[0].text
if prefixes != None:
key = f'{prefixes[i]}{key}'
opttype = opt.xpath('/@class').split(' ')[1]
if flags == FLAG_PROP_TYPE:
if proptyping and opttype in convert:
value = convert[opttype](value)
if opttype == 'config-option-number'\
or opttype == 'config-option-select':
value = int(value)
elif opttype == 'config-option-toggle':
value = bool(value)
result[key] = value
return result

View file

@ -7,7 +7,7 @@ from cloudscraper import CloudScraper
from typing import Optional, Union
from . import atjsparse
from .aterrors import CredentialsError, CloudflareError
from .aterrors import CredentialsError
REQUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:68.0) Gecko/20100101 Goanna/4.8 Firefox/68.0 PaleMoon/29.4.0.2'
@ -15,17 +15,14 @@ class AternosConnect:
def __init__(self) -> None:
pass
self.session = CloudScraper()
def parse_token(self, response:Optional[Union[str,bytes]]=None) -> str:
def parse_token(self) -> str:
if response == None:
loginpage = self.request_cloudflare(
f'https://aternos.org/go/', 'GET'
).content
pagetree = lxml.html.fromstring(loginpage)
else:
pagetree = lxml.html.fromstring(response)
try:
pagehead = pagetree.head
@ -58,9 +55,10 @@ class AternosConnect:
def generate_aternos_rand(self, randlen:int=16) -> str:
rand_arr = []
for i in range(randlen+1):
rand_arr.append('')
# a list with randlen+1 empty strings:
# generate a string with spaces,
# then split it by space
rand_arr = (' ' * (randlen+1)).split(' ')
rand = random.random()
rand_alphanum = self.convert_num(rand, 36) + ('0' * 17)
@ -88,40 +86,24 @@ class AternosConnect:
return result
def request_cloudflare(
self, url:str, method:str, retries:int=10,
self, url:str, method:str,
params:Optional[dict]=None, data:Optional[dict]=None,
headers:Optional[dict]=None, reqcookies:Optional[dict]=None,
sendtoken:bool=False, redirect:bool=True) -> Response:
cftitle = '<title>Please Wait... | Cloudflare</title>'
if params == None:
params = {}
if headers == None:
headers = {}
params = params if params else {}
data = data if data else {}
headers = headers if headers else {}
reqcookies = reqcookies if reqcookies else {}
headers['User-Agent'] = REQUA
if sendtoken:
url += f'?TOKEN={self.token}&SEC={self.sec}'
params['TOKEN'] = self.token
params['SEC'] = self.sec
try:
cookies = self.session.cookies
except AttributeError:
cookies = None
countdown = retries
while True:
self.session = CloudScraper()
if cookies != None:
self.session.cookies = cookies
if reqcookies != None:
for cookiekey in reqcookies:
self.session.cookies.set(cookiekey, reqcookies[cookiekey])
time.sleep(1)
# requests.cookies.CookieConflictError bugfix
reqcookies['ATERNOS_SESSION'] = self.session.cookies['ATERNOS_SESSION']
del self.session.cookies['ATERNOS_SESSION']
if method == 'POST':
req = self.session.post(
@ -131,17 +113,9 @@ class AternosConnect:
)
else:
req = self.session.get(
url, params=params,
url, params={**params, **data},
headers=headers, cookies=reqcookies,
allow_redirects=redirect
)
if not cftitle in req.text:
break
if not countdown > 0:
raise CloudflareError(
'The retries limit has been reached'
)
countdown -= 1
return req

View file

@ -9,6 +9,3 @@ class ServerError(AternosError):
class FileError(AternosError):
pass
class CloudflareError(AternosError):
pass

View file

@ -17,51 +17,53 @@ class AternosFile:
def __init__(
self, atserv:'AternosServer',
path:str, name:str, ftype:int=FileType.file,
size:Union[int,float]=0, dlallowed:bool=False) -> None:
size:Union[int,float]=0) -> None:
self.atserv = atserv
self._path = path
self._name = name
self._full = path + name
self._ftype = ftype
self._size = float(size)
self._dlallowed = dlallowed
def delete(self) -> None:
self.atserv.atserver_request(
'https://aternos.org/panel/ajax/delete.php',
'POST', data={'file': self._name},
'POST', data={'file': self._full},
sendtoken=True
)
def get_content(self) -> bytes:
file = self.atserv.atserver_request(
f'https://aternos.org/panel/ajax/files/download.php',
'https://aternos.org/panel/ajax/files/download.php',
'GET', params={
'file': self._path
'file': self._full
}
)
if not self._dlallowed:
raise FileError('Downloading this file is not allowed. Try to get text')
if file.content == b'{"success":false}':
raise FileError('Unable to download the file. Try to get text')
return file.content
def set_content(self, value:bytes) -> None:
self.atserv.atserver_request(
f'https://aternos.org/panel/ajax/save.php',
'POST', data={
'file': self._path,
'file': self._full,
'content': value
}, sendtoken=True
)
def get_text(self) -> str:
editor = self.atserv.atserver_request(
f'https://aternos.org/files/{self._name}', 'GET'
)
edittree = lxml.html.fromstring(editor.content)
editfield = edittree.xpath('//div[@class="ace_layer ace_text-layer"]')[0]
editlines = editfield.xpath('/div[@class="ace_line"]')
editlines = edittree.xpath('//div[@class="ace_line"]')
rawlines = []
for line in editlines:
@ -69,6 +71,7 @@ class AternosFile:
return rawlines
def set_text(self, value:str) -> None:
self.set_content(value.encode('utf-8'))
@property
@ -79,6 +82,10 @@ class AternosFile:
def name(self) -> str:
return self._name
@property
def full(self) -> str:
return self._full
@property
def is_dir(self) -> bool:
if self._ftype == FileType.directory:
@ -94,7 +101,3 @@ class AternosFile:
@property
def size(self) -> float:
return self._size
@property
def dlallowed(self) -> bool:
return self._dlallowed

View file

@ -18,21 +18,18 @@ class FileManager:
f'https://aternos.org/files/{path}', 'GET'
)
filestree = lxml.html.fromstring(filesreq.content)
fileslist = filestree.xpath(
'//div[@class="files"]' + \
'/div[@class="directory dropzone"]' + \
'/div[@class="file clickable"]'
)
fileslist = filestree.xpath('//div[@class="file clickable"]')
files = []
for f in fileslist:
ftype_raw = f.xpath('/@data-type')
ftype_raw = f.xpath('@data-type')[0]
ftype = FileType.file \
if ftype_raw == 'file' \
else FileType.directory
fsize_raw = f.xpath('/div[@class="filesize"]')
fsize_raw = f.xpath('./div[@class="filesize"]')
print(fsize_raw)
fsize = 0
if len(fsize_raw) > 0:
@ -45,19 +42,14 @@ class FileManager:
except ValueError:
fsize = -1
dlbutton = f.xpath('/div[contains(@class,"js-download-file ")]')
dlallowed = False
if len(dlbutton) > 0:
dlallowed = True
fullpath = f.xpath('/@data-path')
fullpath = f.xpath('@data-path')[0]
filepath = fullpath[:fullpath.rfind('/')]
filename = fullpath[fullpath.rfind('/'):]
files.append(
AternosFile(
self.atserv,
filepath, filename,
ftype, fsize, dlallowed
ftype, fsize
)
)

View file

@ -1,6 +1,6 @@
import enum
import lxml.html
from typing import List
from typing import List, Union
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@ -15,40 +15,34 @@ class Lists(enum.Enum):
class PlayersList:
def __init__(self, lst:str, atserv:'AternosServer') -> None:
for ltype in Lists:
if ltype.value == lst:
break
else:
raise ValueError(
'Incorrect players list type! ' + \
'Use atplayers.Lists enum'
)
def __init__(self, lst:Union[str,Lists], atserv:'AternosServer') -> None:
self.atserv = atserv
self.lst = lst
self.lst = Lists(lst)
self.players = []
self.parsed = False
def list_players(self, cache:bool=True) -> List[str]:
if cache:
if cache and self.parsed:
return self.players
listreq = self.atserv.atserver_request(
f'https://aternos.org/players/{self.lst}', 'GET'
f'https://aternos.org/players/{self.lst.value}',
'GET'
)
listtree = lxml.html.fromstring(listreq.content)
items = listtree.xpath(
'//div[@class="player-list"]' + \
'/div[@class="list-item-container"]' + \
'/div[@class="list-item"]'
'//div[@class="list-item"]'
)
result = []
for i in items:
name = i.xpath('./div[@class="list-name"]')
result.append(name)
result.append(name[0].text.strip())
self.players = result
self.parsed = True
return result
def add(self, name:str) -> None:
@ -56,9 +50,9 @@ class PlayersList:
self.atserv.atserver_request(
'https://aternos.org/panel/ajax/players/add.php',
'POST', data={
'list': self.lst,
'list': self.lst.value,
'name': name
}
}, sendtoken=True
)
self.players.append(name)
@ -68,9 +62,9 @@ class PlayersList:
self.atserv.atserver_request(
'https://aternos.org/panel/ajax/players/remove.php',
'POST', data={
'list': self.lst,
'list': self.lst.value,
'name': name
}
}, sendtoken=True
)
for i, j in enumerate(self.players):

View file

@ -1,7 +1,5 @@
import enum
import re
import json
import lxml.html
from requests import Response
from typing import Optional
@ -12,9 +10,6 @@ from .atconf import AternosConfig
from .atplayers import PlayersList
from .atwss import AternosWss
JAVA = 0
BEDROCK = 1
class Edition(enum.IntEnum):
java = 0
bedrock = 1
@ -32,37 +27,22 @@ class AternosServer:
def __init__(
self, servid:str,
atconn:AternosConnect,
savelog:bool=True) -> None:
atconn:AternosConnect) -> None:
self.servid = servid
self.atconn = atconn
self.savelog = savelog
self.log = []
def fetch(self) -> None:
servreq = self.atserver_request(
'https://aternos.org/server', 'GET'
'https://aternos.org/panel/ajax/status.php',
'GET', sendtoken=True
)
servtree = lxml.html.fromstring(servreq.content)
self._info = json.loads(servreq.content)
self._info = json.loads(
re.search(
r'var\s*lastStatus\s*=\s*({.*})',
servtree.head.text_content()
)[1]
)
self._ram = 0
self._tps = 0
def wss(self, autoconfirm:bool=False) -> AternosWss:
self.atconn.parse_token(servreq.content)
self.atconn.generate_sec()
async def wss(self) -> AternosWss:
return AternosWss(
self.atconn.session.cookies,
self.servid
)
return AternosWss(self, autoconfirm)
def start(self, headstart:bool=False, accepteula:bool=True) -> None:
@ -98,7 +78,7 @@ class AternosServer:
elif error == 'file':
raise ServerError(
'File server is unavailbale, view status.aternos.gmbh'
'File server is unavailbale, view https://status.aternos.gmbh'
)
elif error == 'size':
@ -155,7 +135,7 @@ class AternosServer:
return AternosConfig(self)
def get_players(self, lst:str) -> PlayersList:
def players(self, lst:str) -> PlayersList:
return PlayersList(lst, self)
@ -227,9 +207,13 @@ class AternosServer:
return self._info['version']
@property
def status(self) -> int:
def status(self) -> str:
return self._info['class']
@property
def status_num(self) -> int:
return int(self._info['status'])
@property
def ram(self) -> int:
return self._ram
return int(self._info['ram'])

View file

@ -3,8 +3,11 @@ import json
import asyncio
import websockets
from typing import Union, Any, Dict, Callable, Coroutine
from typing import TYPE_CHECKING
from .atconnect import REQUA
if TYPE_CHECKING:
from .atserver import AternosServer
class Streams(enum.IntEnum):
status = 0
@ -15,11 +18,19 @@ class Streams(enum.IntEnum):
class AternosWss:
def __init__(self, session:str, servid:str) -> None:
def __init__(self, atserv:'AternosServer', autoconfirm:bool=False) -> None:
self.session = session
self.servid = servid
self.atserv = atserv
self.cookies = atserv.atconn.session.cookies
self.session = self.cookies['ATERNOS_SESSION']
self.servid = self.cookies['ATERNOS_SERVER']
self.recv = {}
self.autoconfirm = autoconfirm
self.confirmed = False
async def confirm(self) -> None:
self.atserv.confirm()
def wssreceiver(self, stream:int) -> Callable[[Callable[[Any],Coroutine[Any,Any,None]]],Any]:
def decorator(func:Callable[[Any],Coroutine[Any,Any,None]]) -> None:
@ -29,18 +40,21 @@ class AternosWss:
async def connect(self) -> None:
headers = [
('Host', 'aternos.org'),
('User-Agent', REQUA),
(
'Cookie',
f'ATERNOS_SESSION={self.session}; ' +\
f'ATERNOS_SESSION={self.session}; ' + \
f'ATERNOS_SERVER={self.servid}'
)
]
self.socket = await websockets.connect(
'wss://aternos.org/hermes',
'wss://aternos.org/hermes/',
origin='https://aternos.org',
extra_headers=headers
)
asyncio.run(wssworker())
await self.wssworker()
async def close(self) -> None:
@ -56,8 +70,8 @@ class AternosWss:
async def wssworker(self) -> None:
keep = asyncio.create_task(keepalive())
msgs = asyncio.create_task(receiver())
keep = asyncio.create_task(self.keepalive())
msgs = asyncio.create_task(self.receiver())
await keep
await msgs
@ -90,6 +104,16 @@ class AternosWss:
msgtype = Streams.status
msg = json.loads(obj['message'])
if not self.autoconfirm:
continue
if msg['class'] == 'queueing' \
and msg['queue']['pending'] == 'pending'\
and not self.confirmed:
t = asyncio.create_task(
self.confirm()
)
await t
if msgtype in self.recv:
t = asyncio.create_task(
self.recv[msgtype](msg)

View file

@ -1,30 +1,56 @@
import re
import unittest
from python_aternos import atjsparse
# Use tests from a file
tests = []
with open('../token.txt', 'rt') as f:
class TestJs2Py(unittest.TestCase):
def setUp(self) -> None:
self.tests = []
with open('../token.txt', 'rt') as f:
lines = re.split(r'[\r\n]', f.read())
del lines[len(lines)-1] # Remove empty string
tests = lines
self.tests = lines
for f in tests:
self.results = [
'2rKOA1IFdBcHhEM616cb'
'2rKOA1IFdBcHhEM616cb'
'2rKOA1IFdBcHhEM616cb'
'2rKOA1IFdBcHhEM616cb'
'2rKOA1IFdBcHhEM616cb'
'2rKOA1IFdBcHhEM616cb'
'2rKOA1IFdBcHhEM616cb'
'2rKOA1IFdBcHhEM616cb'
'2rKOA1IFdBcHhEM616cb'
'2iXh5W5uEYq5fWJIazQ6'
'CuUcmZ27Fb8bVBNw12Vj'
'YPPe8Ph7vzYaZ9PF9oQP'
'UfLlemvKEE16ltk0hZNM'
'q6pYdP6r7xiVHhbotvlN'
'q6pYdP6r7xiVHhbotvlN'
'XAIbksgkVX9JYboMDI7D'
]
def test_base64(self) -> None:
encoded = 'QEhlbGxvIFdvcmxkIQ=='
decoded = atjsparse.atob(encoded)
self.assertEqual(decoded, '@Hello World!')
def test_conv(self) -> None:
token = '(() => {window["AJAX_TOKEN"]=("2r" + "KO" + "A1" + "IFdBcHhEM" + "61" + "6cb");})();'
f = atjsparse.to_ecma5_function(token)
self.assertEqual(f, '(function(){window["AJAX_TOKEN"]=("2r" + "KO" + "A1" + "IFdBcHhEM" + "61" + "6cb");})()')
def test_exec(self) -> None:
for i, f in enumerate(self.tests):
ctx = atjsparse.exec(f)
print(ctx.window['AJAX_TOKEN'])
res = ctx.window['AJAX_TOKEN']
self.assertEqual(res, self.results[i])
# 2rKOA1IFdBcHhEM616cb
# 2rKOA1IFdBcHhEM616cb
# 2rKOA1IFdBcHhEM616cb
# 2rKOA1IFdBcHhEM616cb
# 2rKOA1IFdBcHhEM616cb
# 2rKOA1IFdBcHhEM616cb
# 2rKOA1IFdBcHhEM616cb
# 2rKOA1IFdBcHhEM616cb
# 2rKOA1IFdBcHhEM616cb
# 2iXh5W5uEYq5fWJIazQ6
# CuUcmZ27Fb8bVBNw12Vj
# YPPe8Ph7vzYaZ9PF9oQP
# UfLlemvKEE16ltk0hZNM
# q6pYdP6r7xiVHhbotvlN
# q6pYdP6r7xiVHhbotvlN
# XAIbksgkVX9JYboMDI7D
def tearDown(self) -> None:
del self.tests
del self.results