Skip to content

Commit

Permalink
Forge support (#5)
Browse files Browse the repository at this point in the history
* rewrite test structure, support some forge versions

* pylint

* pylint

* lazily start output thread
  • Loading branch information
Ableytner authored Mar 15, 2024
1 parent 0584ac8 commit f60eeb9
Show file tree
Hide file tree
Showing 19 changed files with 449 additions and 149 deletions.
5 changes: 3 additions & 2 deletions .pylintrc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[MAIN]
max-line-length=120
max-line-length=150

[MESSAGES CONTROL]

Expand All @@ -23,4 +23,5 @@ disable=too-many-arguments,
too-few-public-methods,
global-statement,
too-many-branches,
broad-exception-raised
broad-exception-raised,
relative-beyond-top-level
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,34 @@
# McServerWrapper

A wrapper for minecraft servers, usable as a python package or from the console.
A python package which wraps around a minecraft server, providing easy access for python programs which want to automatically manage minecraft servers.

## Overview

Examples can be found in the **examples** folder.

Supported Minecraft versions:

### Vanilla

Supports versions 1.7.10 to 1.20.4 (excluding 1.8.0, which is severely bugged).

### Forge

Supports version 1.7.10 to 1.16.5 as well as 1.20.3 to 1.20.4

## Installation

### PyPi

Not yet available

### Github

To install the latest version directly from Github, run the following command:
```pip install git+https://github.com/mcserver-tools/mcserverwrapper.git```

This will automatically install all other dependencies.

## Run tests locally

In order to run tests locally, there are a few things that have to be setup:
Expand Down
7 changes: 7 additions & 0 deletions mcserverwrapper/src/error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Module containing all McServerWrapper-related errors"""

class McServerWrapperError(Exception):
"""The base exception, can be used to catch all other McServerWrapper-related errors"""

class ServerExitedError(McServerWrapperError):
"""An error occuring if the minecraft server unexpectedly crashed"""
42 changes: 30 additions & 12 deletions mcserverwrapper/src/server/base_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

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

class BaseServer:
"""The base server, containing server type-independent functionality"""
Expand All @@ -37,9 +38,14 @@ def start(self, blocking=True):

# 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"))):
or not os.path.isfile(os.path.join(self.server_path, "eula.txt"))
and self.get_child_status(0.1) is None):
sleep(0.1)

# check if server crashed
if self.get_child_status(0.1) not in [None, 0]:
raise ServerExitedError(f"Server unexpectedly exited with exit code {self.get_child_status(0.1)}")

# if blocking is True, wait for the server to finish starting
if blocking:
self._wait_for_startup()
Expand All @@ -62,6 +68,7 @@ def kill(self):
def execute_command(self, command: str) -> None:
"""Send a given command to the server"""

logger.log(f"Sending command: {command}")
self._child.sendline(command)

def get_child_status(self, timeout: int) -> int | None:
Expand Down Expand Up @@ -167,24 +174,35 @@ def _format_output(self, raw_text: bytes) -> str:

return text_str

# pylint: disable=attribute-defined-outside-init
def _parse_and_save_version_string(self, version_raw):
"""Search the given version string to find out the version type"""
# pylint: disable=attribute-defined-outside-init, unreachable, protected-access, undefined-variable
@staticmethod
def _check_jar(jar_file: str) -> McVersion | None:
"""Search the given jar file to find the version"""

raise NotImplementedError()

self.version = version_raw
filename = jar_file.rsplit(".", maxsplit=1)[0]
version_type = None
version = None

if re.search(r"^1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?$", version_raw) is not None:
if re.search(r"^1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?$", filename) is not None:
self._version_type = "vanilla"
elif re.search(r"^[1-2][0-9]w[0-9]{1,2}[a-z]", version_raw) is not None:
elif re.search(r"^[1-2][0-9]w[0-9]{1,2}[a-z]", filename) is not None:
self._version_type = "snapshot"
elif re.search(r"^Paper 1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?$", version_raw) is not None:
elif re.search(r"^Paper 1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?$", filename) is not None:
self._version_type = "paper"
elif re.search(r"^PaperSpigot 1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?$", version_raw) is not None:
elif re.search(r"^PaperSpigot 1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?$", filename) is not None:
self._version_type = "paper"
elif re.search(r"^Spigot 1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?$", version_raw) is not None:
elif re.search(r"^Spigot 1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?$", filename) is not None:
self._version_type = "spigot"
elif re.search(r"^CraftBukkit 1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?$", version_raw) is not None:
elif re.search(r"^CraftBukkit 1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?$", filename) is not None:
self._version_type = "bukkit"
else:
self._version_type = "unknown"
# pylint: enable=attribute-defined-outside-init
# pylint: enable=attribute-defined-outside-init, unreachable, protected-access, undefined-variable

@staticmethod
def _check_jar_name(jar_file: str) -> McVersion | None:
"""Search the name of the given jar file to find the version"""

raise NotImplementedError()
62 changes: 62 additions & 0 deletions mcserverwrapper/src/server/forge_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Module containing the ForgeServer class"""

from __future__ import annotations

import json
import os
import re
from zipfile import ZipFile
from .base_server import BaseServer
from ..mcversion import McVersion, McVersionType

class ForgeServer(BaseServer):
"""
Class representing a Forge server
More info about Forge: https://minecraftforge.net/
"""

VERSION_TYPE = McVersionType.FORGE

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

super().execute_command(command)

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

@classmethod
def _check_jar(cls, jar_file: str) -> McVersion | None:
"""Search the given jar file to find the version"""

with ZipFile(jar_file, "r") as zf:
# for Minecraft 1.14+
version_json = None
for zip_fileinfo in zf.filelist:
if zip_fileinfo.filename == "version.json":
version_json = json.loads(zf.read(zip_fileinfo))
if version_json is not None:
# regex for old forge versions
pattern = r"^1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?\-[fF]orge"
if re.search(pattern, version_json["id"]) is not None:
vers = [x.group() for x in re.finditer(pattern, version_json["id"])]
return McVersion(vers[0].split("-", maxsplit=1)[0], cls.VERSION_TYPE)

# no version was found
return None

@classmethod
def _check_jar_name(cls, jar_file: str) -> McVersion | None:
"""Search the name of the given jar file to find the version"""

jar_filename = jar_file.rsplit(os.sep, maxsplit=1)[1]

# common filename pattern
pattern = r"^forge-1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?\-.*.jar$"
if re.search(pattern, jar_filename) is not None:
vers = [x.group() for x in re.finditer(pattern, jar_filename)]
return McVersion(vers[0].split("-", maxsplit=2)[1], cls.VERSION_TYPE)

# no version was found
return None
16 changes: 15 additions & 1 deletion mcserverwrapper/src/server/paper_server.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Module containing the PaperServer class"""

from __future__ import annotations

from .base_server import BaseServer
from ..mcversion import McVersionType
from ..mcversion import McVersion, McVersionType

class PaperServer(BaseServer):
"""
Expand All @@ -19,3 +21,15 @@ def execute_command(self, command: str):

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

@staticmethod
def _check_jar(jar_file: str) -> McVersion | None:
"""Search the given jar file to find the version"""

raise NotImplementedError()

@staticmethod
def _check_jar_name(jar_file: str) -> McVersion | None:
"""Search the name of the given jar file to find the version"""

raise NotImplementedError()
77 changes: 30 additions & 47 deletions mcserverwrapper/src/server/server_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

from __future__ import annotations

import json
import pathlib
import os
import re
from zipfile import ZipFile

from mcserverwrapper.src.util import logger

from .base_server import BaseServer
from .vanilla_server import VanillaServer
from .forge_server import ForgeServer
from ..mcversion import McVersion, McVersionType

DEFAULT_START_CMD = "java -Xmx4G -Xms4G -jar server.jar nogui"
Expand All @@ -22,6 +22,11 @@ class ServerBuilder:

__create_key = object()

SERVER_CLASSES: dict[int, type[BaseServer]] = {
McVersionType.FORGE: ForgeServer,
McVersionType.VANILLA: VanillaServer # !!! VanillaServer has to be the last element !!!
}

@classmethod
def from_jar(cls, jar_file: str) -> ServerBuilder:
"""
Expand Down Expand Up @@ -94,9 +99,8 @@ def build(self) -> BaseServer:

server_path = pathlib.Path(self._jar_path).parent.resolve()

server = None
if self._mcv.type == McVersionType.VANILLA:
server = VanillaServer(server_path, self._mcv, self._port, self._start_cmd)
clazz = self.SERVER_CLASSES[self._mcv.type]
server = clazz(server_path, self._mcv, self._port, self._start_cmd)

assert server is not None
return server
Expand All @@ -110,46 +114,25 @@ def __init__(self, create_key, jar_path: str, mcv: McVersion) -> None:
self._start_cmd = DEFAULT_START_CMD
self._port = None

@staticmethod
def _check_jar(jar_file: str) -> McVersion:
# pylint: disable=protected-access
@classmethod
def _check_jar(cls, 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:
if jar_file:
pass

@staticmethod
def _check_jar_vanilla(jar_file: str) -> McVersion | None:
with ZipFile(jar_file, "r") as zf:
# for Minecraft 1.14+
version_json = None
for zip_fileinfo in zf.filelist:
if zip_fileinfo.filename == "version.json":
version_json = json.loads(zf.read(zip_fileinfo))
if version_json is not None:
return McVersion(version_json["name"], McVersionType.VANILLA)

# for Mineraft 1.13.2-
mcs_class = None
for zip_fileinfo in zf.filelist:
if zip_fileinfo.filename == "net/minecraft/server/MinecraftServer.class":
mcs_class = zf.read(zip_fileinfo)
if mcs_class is not None:
vers = [x.group() for x in re.finditer(r"1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?",
mcs_class.decode("utf8", errors="ignore"))]
if len(vers) != 0:
return McVersion(vers[0], McVersionType.VANILLA)

# no version was found
return None
for clazz in cls.SERVER_CLASSES.values():
mcv = clazz._check_jar(jar_file)
if mcv is not None:
logger.log(f"Detected Minecraft version: {mcv}")
return mcv

logger.log(f"Minecraft version could not be read from {jar_file.rsplit(os.sep, maxsplit=1)[1]}," + \
" checking filename")

for clazz in cls.SERVER_CLASSES.values():
mcv = clazz._check_jar_name(jar_file)
if mcv is not None:
logger.log(f"Detected Minecraft version: {mcv}")
return mcv

raise ValueError(f"Minecraft version could not be identified from {jar_file.rsplit(os.sep, maxsplit=1)[1]}")
# pylint: enable=protected-access
53 changes: 52 additions & 1 deletion mcserverwrapper/src/server/vanilla_server.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
"""Module containing the VanillaServer class"""

from __future__ import annotations

import json
import os
import re
from zipfile import ZipFile
from .base_server import BaseServer
from ..mcversion import McVersionType
from ..mcversion import McVersion, McVersionType

class VanillaServer(BaseServer):
"""Class representing a Vanilla (normal/unmodded) Minecraft server"""
Expand All @@ -16,3 +22,48 @@ def execute_command(self, command: str):

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

@classmethod
def _check_jar(cls, jar_file: str) -> McVersion | None:
"""Search the given jar file to find the version"""

with ZipFile(jar_file, "r") as zf:
# for Minecraft 1.14+
version_json = None
for zip_fileinfo in zf.filelist:
if zip_fileinfo.filename == "version.json":
version_json = json.loads(zf.read(zip_fileinfo))
if version_json is not None:
return McVersion(version_json["name"], cls.VERSION_TYPE)

# for Mineraft 1.13.2-
mcs_class = None
for zip_fileinfo in zf.filelist:
if zip_fileinfo.filename == "net/minecraft/server/MinecraftServer.class":
mcs_class = zf.read(zip_fileinfo)
if mcs_class is not None:
vers = [x.group() for x in re.finditer(r"1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?",
mcs_class.decode("utf8", errors="ignore"))]
if len(vers) != 0:
return McVersion(vers[0], cls.VERSION_TYPE)

# no version was found
return None

@classmethod
def _check_jar_name(cls, jar_file: str) -> McVersion | None:
"""Search the name of the given jar file to find the version"""

jar_filename = jar_file.rsplit(os.sep, maxsplit=1)[1]

# vanilla server jars are usually just 'server.jar', which doesn't help here
if jar_filename == "server.jar":
return None

if re.search(r"^minecraft_server\.1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?\.jar", jar_filename) is not None:
vers = [x.group() for x in re.finditer(r"^minecraft_server\.1\.[1-2]{0,1}[0-9](\.[0-9]{1,2})?\.jar",
jar_filename)]
return McVersion(vers[0].replace("minecraft_server.", "").replace(".jar", ""), cls.VERSION_TYPE)

# no version was found
return None
Loading

0 comments on commit f60eeb9

Please sign in to comment.