Skip to content

Commit

Permalink
transition server to builder architecture
Browse files Browse the repository at this point in the history
  • Loading branch information
Ableytner committed Mar 12, 2024
1 parent 4e55d72 commit 14ad342
Show file tree
Hide file tree
Showing 13 changed files with 243 additions and 118 deletions.
8 changes: 4 additions & 4 deletions mcserverwrapper/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ def main():
def main2():
"""Function used for testing, don't call this!"""

wrapper = Wrapper(server_path=os.path.join(pathlib.Path(__file__).parent.parent.resolve(),
"mcserverwrapper", "test", "temp"),
wrapper = Wrapper(os.path.join(pathlib.Path(__file__).parent.parent.resolve(),
"mcserverwrapper", "test", "temp", "server.jar"),
print_output=True)

try:
Expand All @@ -41,5 +41,5 @@ def main2():
raise e

if __name__ == "__main__":
main()
# main2()
# main()
main2()
60 changes: 60 additions & 0 deletions mcserverwrapper/src/mcversion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import re


class McVersionType:
UNKNOWN = 0
VANILLA = 1
SNAPSHOT = 2
FORGE = 3
PAPER = 4
SPIGOT = 5
BUKKIT = 6

@staticmethod
def get_all() -> dict[str, int]:
return {
"UNKNOWN": 0,
"VANILLA": 1,
"SNAPSHOT": 2,
"FORGE": 3,
"PAPER": 4,
"SPIGOT": 5,
"BUKKIT": 6
}

@staticmethod
def get_name(type: int) -> str:
for name, type_id in McVersionType.get_all().values():
if type_id == type:
return name

class McVersion:
def __init__(self, name: str, type: int) -> None:
if not isinstance(name, str):
raise TypeError(f"Expected str, got {type(name)}")
if not isinstance(type, int):
raise TypeError(f"Expected int, got {type(type)}")

if re.search(r"^1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?$", name) is None:
raise ValueError(f"Expected version regex to match with {name}")

self.name = name
self.type = type

split_name = [int(item) for item in name.split(".")]
if len(split_name) == 2:
self.id = split_name[0] * 10**4 + split_name[1] * 10**2
elif len(split_name) == 3:
self.id = split_name[0] * 10**4 + split_name[1] * 10**2 + split_name[2]
else:
raise ValueError(f"Expected to version to have at most 3 subparts, but received {split_name}")

name: str
type: int
id: int

def __str__(self) -> str:
if self.type == McVersionType.VANILLA:
return f"{self.name}"
else:
return f"{McVersionType.get_name(self.type)} {self.name}"
13 changes: 13 additions & 0 deletions mcserverwrapper/src/server/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Export server classes"""

from .server_builder import ServerBuilder
from .base_server import BaseServer
from .paper_server import PaperServer
from .vanilla_server import VanillaServer

__exports__ = [
ServerBuilder,
BaseServer,
PaperServer,
VanillaServer
]
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""A module ocntining the server class"""
"""A module contining the base server class"""

from __future__ import annotations

Expand All @@ -16,17 +16,19 @@
import pexpect
from pexpect import popen_spawn

from . import info_getter, logger
from ..mcversion import McVersion
from ..util import info_getter, logger

class Server():
"""The core of the wrapper, communicates directly with the minecraft servers"""
class BaseServer:
"""The base server, containing server type-independent functionality"""

def __init__(self, server_path: str) -> None:
self._server_path = server_path
def __init__(self, server_path: str, version: McVersion) -> None:
self.server_path = server_path
self.version = version
self.child = None
self._port = None
self._version = None
self._version_type = None

VERSION_TYPE = None

def start(self, command, cwd=None, blocking=True):
"""Starts the minecraft server"""
Expand All @@ -35,8 +37,8 @@ def start(self, command, cwd=None, blocking=True):
self.child = popen_spawn.PopenSpawn(cmd=command, cwd=cwd, timeout=1)

# wait for files to get generated or server to exit
while (not os.path.isfile(os.path.join(self._server_path, "./server.properties")) \
or not os.path.isfile(os.path.join(self._server_path, "eula.txt"))):
while (not os.path.isfile(os.path.join(self.server_path, "./server.properties")) \
or not os.path.isfile(os.path.join(self.server_path, "eula.txt"))):
sleep(0.1)

# read the port from the server.properties file
Expand Down Expand Up @@ -64,25 +66,11 @@ def kill(self):
else:
self.child.kill(signal.SIGTERM)

def execute_command(self, command: str):
def execute_command(self, command: str) -> None:
"""Send a given command to the server"""

# vanilla server commands must start with a '/'
if self._version_type in ["vanilla", "snapshot"] and not command.startswith("/"):
command = "/" + command
# paper server commands don't start with a '/'
elif self._version_type in ["paper", "spigot", "bukkit"] and command.startswith("/"):
command = command[1::]

self.child.sendline(command)

if command == "/stop":
logger.log("Stopping server")
status = self.get_child_status(20)
if status is None:
logger.log("Server did not stop within 20 seconds")
self.kill()

def get_child_status(self, timeout: int) -> int | None:
"""
Return the exit status of the server process, or None if the process is still alive after the timeout
Expand Down Expand Up @@ -153,7 +141,7 @@ def read_output(self, timeout=None) -> Generator[str, None, None]:
def _read_port_from_properties(self):
"""Reads and stores the port from the server.properties file"""

with open(os.path.join(self._server_path, "server.properties"), "r", encoding="utf8") as properties_file:
with open(os.path.join(self.server_path, "server.properties"), "r", encoding="utf8") as properties_file:
lines = properties_file.read().splitlines()
for line in lines:
if "server-port" in line:
Expand All @@ -170,7 +158,14 @@ def _wait_for_startup(self):
sleep(1)
response = info_getter.ping_address_with_return("127.0.0.1", self._port)

self._parse_and_save_version_string(response.version.name)
# self._parse_and_save_version_string(response.version.name)

def _stopping(self):
logger.log("Stopping server")
status = self.get_child_status(20)
if status is None:
logger.log("Server did not stop within 20 seconds")
self.kill()

def _format_output(self, raw_text: bytes) -> str:
# remove line breaks
Expand All @@ -196,7 +191,7 @@ def _format_output(self, raw_text: bytes) -> str:
def _parse_and_save_version_string(self, version_raw):
"""Search the given version string to find out the version type"""

self._version = version_raw
self.version = version_raw

if re.search(r"^1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?$", version_raw) is not None:
self._version_type = "vanilla"
Expand Down
16 changes: 16 additions & 0 deletions mcserverwrapper/src/server/paper_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from .base_server import BaseServer
from ..mcversion import McVersion, McVersionType

class PaperServer(BaseServer):
VERSION_TYPE = McVersionType.PAPER

def execute_command(self, command: str):
if command.startswith("/"):
command = command[1::]

ret = super().execute_command(command)

if command == "stop":
self._stopping()

return ret
78 changes: 78 additions & 0 deletions mcserverwrapper/src/server/server_builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import json
import pathlib
import os
from zipfile import ZipFile

from .base_server import BaseServer
from .vanilla_server import VanillaServer
from ..mcversion import McVersion, McVersionType
from ..util import logger

class ServerBuilder:
"""Builder clas to create a new Server object"""

__create_key = object()

@classmethod
def from_jar(cls, jar_file: str):
if not isinstance(jar_file, str):
raise TypeError(f"Expected str, got {type(jar_file)}")

if not os.path.isfile(jar_file):
raise FileNotFoundError(f"Jarfile {jar_file} not found")
if not jar_file.endswith(".jar"):
raise FileNotFoundError("Expected a .jar file")

mcv = ServerBuilder._check_jar(jar_file)
assert isinstance(mcv, McVersion)

server_path = pathlib.Path(jar_file).parent.resolve()

server = None
match mcv.type:
case McVersionType.VANILLA:
server = VanillaServer(server_path, mcv)

assert server is not None

builder = ServerBuilder(cls.__create_key, server)
return builder

def build(self):
assert isinstance(self._server, BaseServer)
return self._server

def __init__(self, create_key, server: BaseServer) -> None:
if create_key != ServerBuilder.__create_key:
raise NotImplementedError("Cannot instantiate builder class")

self._server = server

@staticmethod
def _check_jar(jar_file: str) -> McVersion:
mcv = None

mcv = ServerBuilder._check_jar_fabric(jar_file)
if mcv is not None:
return mcv

mcv = ServerBuilder._check_jar_vanilla(jar_file)
if mcv is not None:
return mcv

raise ValueError(f"Minecraft version could not be identified from {jar_file}")

@staticmethod
def _check_jar_fabric(jar_file: str) -> McVersion | None:
return None

@staticmethod
def _check_jar_vanilla(jar_file: str) -> McVersion | None:
version_json = None
with ZipFile(jar_file, "r") as zf:
for zip_fileinfo in zf.filelist:
if zip_fileinfo.filename == "version.json":
version_json = json.loads(zf.read(zip_fileinfo))
if version_json is None:
return None
return McVersion(version_json["name"], McVersionType.VANILLA)
16 changes: 16 additions & 0 deletions mcserverwrapper/src/server/vanilla_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from .base_server import BaseServer
from ..mcversion import McVersion, McVersionType

class VanillaServer(BaseServer):
VERSION_TYPE = McVersionType.VANILLA

def execute_command(self, command: str):
if not command.startswith("/"):
command = "/" + command

ret = super().execute_command(command)

if command == "/stop":
self._stopping()

return ret
8 changes: 8 additions & 0 deletions mcserverwrapper/src/util/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Export util classes"""

from . import info_getter, logger

__exports__ = [
info_getter,
logger
]
File renamed without changes.
File renamed without changes.
22 changes: 12 additions & 10 deletions mcserverwrapper/src/wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,28 @@
import atexit
import os
import os.path
import pathlib
import sys
from queue import Queue
from threading import Thread
from time import sleep

from .server import Server
from . import logger, server_properties_helper
from .util import logger

from .server import ServerBuilder
from . import server_properties_helper

DEFAULT_START_CMD = "java -Xmx4G -Xms4G -jar server.jar nogui"

class Wrapper():
"""The outer shell of the wrapper, handling inputs and outputs"""

def __init__(self, server_start_command=None, server_property_args=None, server_path=None,
def __init__(self, jarfile_path: str = "server.jar", server_start_command=None, server_property_args=None,
print_output=True) -> None:
if server_path is None:
server_path = os.getcwd()
self.server_path = server_path
self.server_jar = jarfile_path
self.server_path = pathlib.Path(jarfile_path).parent.resolve()

logger.setup(server_path)
logger.setup(self.server_path)

if server_start_command is not None:
self.server_start_command = server_start_command
Expand All @@ -31,9 +33,9 @@ def __init__(self, server_start_command=None, server_property_args=None, server_
else:
self.server_start_command = DEFAULT_START_CMD

self.server_property_args = server_properties_helper.parse_properties_args(server_path, server_property_args)
self.server_property_args = server_properties_helper.parse_properties_args(self.server_path, server_property_args)

self.server = Server(self.server_path)
self.server = ServerBuilder.from_jar(jarfile_path).build()
atexit.register(self.stop)

# delete old logfile
Expand Down Expand Up @@ -86,7 +88,7 @@ def server_running(self) -> bool:
def _run_temp_server(self):
"""Start a temporary server to generate server.properties and eula.txt"""

tempserver = Server(self.server_path)
tempserver = ServerBuilder.from_jar(self.server_jar).build()
atexit.register(tempserver.stop)

try:
Expand Down
Loading

0 comments on commit 14ad342

Please sign in to comment.