Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Forge support #5

Merged
merged 4 commits into from
Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading