minestrapper 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. minestrapper-0.1.0/LICENSE +21 -0
  2. minestrapper-0.1.0/PKG-INFO +8 -0
  3. minestrapper-0.1.0/README.md +112 -0
  4. minestrapper-0.1.0/pyproject.toml +18 -0
  5. minestrapper-0.1.0/setup.cfg +4 -0
  6. minestrapper-0.1.0/src/minestrapper/__init__.py +4 -0
  7. minestrapper-0.1.0/src/minestrapper/config_handler.py +50 -0
  8. minestrapper-0.1.0/src/minestrapper/features/__init__.py +0 -0
  9. minestrapper-0.1.0/src/minestrapper/features/feature.py +13 -0
  10. minestrapper-0.1.0/src/minestrapper/features/handle_built_in_features.py +35 -0
  11. minestrapper-0.1.0/src/minestrapper/features/server_resource_pack/__init__.py +0 -0
  12. minestrapper-0.1.0/src/minestrapper/features/server_resource_pack/address_validator.py +48 -0
  13. minestrapper-0.1.0/src/minestrapper/features/server_resource_pack/path_resolver.py +26 -0
  14. minestrapper-0.1.0/src/minestrapper/features/server_resource_pack/server_resource_pack.py +87 -0
  15. minestrapper-0.1.0/src/minestrapper/features/state_styles/__init__.py +0 -0
  16. minestrapper-0.1.0/src/minestrapper/features/state_styles/state_styles.py +59 -0
  17. minestrapper-0.1.0/src/minestrapper/logger.py +76 -0
  18. minestrapper-0.1.0/src/minestrapper/server.py +124 -0
  19. minestrapper-0.1.0/src/minestrapper/state_handler.py +44 -0
  20. minestrapper-0.1.0/src/minestrapper/util/__init__.py +0 -0
  21. minestrapper-0.1.0/src/minestrapper/util/ansi_colors.py +19 -0
  22. minestrapper-0.1.0/src/minestrapper/util/get_state_from_line.py +30 -0
  23. minestrapper-0.1.0/src/minestrapper.egg-info/PKG-INFO +8 -0
  24. minestrapper-0.1.0/src/minestrapper.egg-info/SOURCES.txt +24 -0
  25. minestrapper-0.1.0/src/minestrapper.egg-info/dependency_links.txt +1 -0
  26. minestrapper-0.1.0/src/minestrapper.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Faizaan J
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: minestrapper
3
+ Version: 0.1.0
4
+ Summary: Minestrapper is a bootstrapper for Minecraft servers that lets you easily add custom features to your server without needing a full mod/plugin loader.
5
+ Author: Faizaan J
6
+ Requires-Python: >=3.9
7
+ License-File: LICENSE
8
+ Dynamic: license-file
@@ -0,0 +1,112 @@
1
+ # MineStrapper
2
+ <p align="center">
3
+ <img width="150" height="150" alt="MineStrapperLogoPlaceholder" src="https://github.com/user-attachments/assets/38e8f391-5c0e-460a-9f05-ea9bc74ebf33" />
4
+ </p>
5
+ <p align="center">MineStrapper is a Python wrapper for running and extending features in a Minecraft <i>Java Edition</i> server.</p>
6
+
7
+ Instead of writing full Java plugins or mods, this lets you hook into the server’s state/lifecycle events (starting, running, idle, stopping, stopped) and add custom functionality directly in Python.
8
+ This means simple features you want to add don't require the overhead of a mod loader (fabric, forge) or a plugin loader (paper, spigot, bukkit) and you can keep the server in its vanilla form.
9
+
10
+ Of course, you can still definitely use your favorite mods or plugins along with this if you'd like.
11
+
12
+ ## Current Features
13
+ - Config Handling
14
+ - Contains a class for managing configuration files of both Minestrapper and Minecraft.
15
+ - Minestrapper config uses a file named `Config.json` in a `/minestrapper` directory relative to the root of the Minecraft Server.
16
+ - Allows for direct editing of `server.properties`.
17
+ - State Handling
18
+ - Contains a class for getting the current state of the server based on the server output.
19
+ - Tracks the following states: `STARTING`, `RUNNING`, `PAUSED`, `STOPPING`, `STOPPED`.
20
+ - Periodic Callbacks
21
+ - A lifecycle function that runs every 20ms.
22
+ - Used for things that need to happen constantly.
23
+ - Mainly isn't used in preference of lifecycle events.
24
+ - New Line Callbacks
25
+ - A lifecycle function that runs everytime a new line is sent from the Minecraft Server process
26
+ - Logger
27
+ - Prints out stuff both from forwarding logs from the Minecraft process and also custom logs from Minestrapper.
28
+ - Keeps Minestrapper specific logs visually consistent with Minecraft Server logs (e.g. "[19:48:06] [Minestrapper/INFO]: Initialized Minestrapper Logger successfully.")
29
+ - Logs to a file named `latest-minestrapper.log` and then replaces the vanilla `latest.log` with it when the server stops.
30
+ - Provides `transformers` to modify each logged line to the output as needed.
31
+ - Built-in features:
32
+ - "State Styles"
33
+ - Updates the terminal title and text color based on the server's current state.
34
+ - Makes it easier to see whether the server is starting, running, paused, stopping, or stopped.
35
+ - "Server Resource Pack"
36
+ - Includes a lightweight HTTP server for serving a server resource pack.
37
+ - Removes the need for an external hosting site for the pack.
38
+ - Keeps resource pack delivery self-contained within the server setup.
39
+
40
+ ## Potential Future Features
41
+ - Backups: Let the owner integrate save backups everytime the server closes locally. It can either be done locally only, with cloud services, or even both.
42
+ - Remote Panels: Allow the owner along with anyone else authorized to remotely turn on and off the server. Only the owner will be able to view logs and run commands however.
43
+ - Custom Commands: Add your own admin commands that can trigger multiple Minecraft commands or even run Python code.
44
+
45
+ ## Installation Guide
46
+ 1. Install Python at [https://www.python.org/](https://www.python.org/) (>=3.1).
47
+ 2. Go to your Minecraft Server directory.
48
+ 3. Make a directory exactly named `minestrapper` and change directory to it.
49
+ ```
50
+ mkdir minestrapper && cd minestrapper
51
+ ```
52
+
53
+ 4. Make a new Python environment.
54
+
55
+ **Windows:**
56
+ ```
57
+ python -m venv .venv
58
+ ```
59
+
60
+ **MacOS / Linux:**
61
+ ```
62
+ python3 -m venv .venv
63
+ ```
64
+
65
+ 5. Activate the environment.
66
+
67
+ **Windows:**
68
+ ```
69
+ .\.venv\Scripts\activate
70
+ ```
71
+
72
+ **MacOS / Linux:**
73
+ ```
74
+ source .venv/bin/activate
75
+ ```
76
+
77
+ 6. Install the `minestrapper` package.
78
+ ```
79
+ pip install minestrapper
80
+ ```
81
+
82
+ 7. Create a file exactly named `Config.json` and edit to your liking.
83
+ ```json
84
+ {
85
+ "$schema": "https://raw.githubusercontent.com/Faizaan-J/mine-strapper/refs/heads/main/Config.schema.json"
86
+ }
87
+ ```
88
+ > [!NOTE]
89
+ > The schema will help you fill out the file so it's recommended to use some kind of app that can read JSON schemas to edit the file easier. There are numerous required fields that you must fill out.
90
+
91
+ 8. Make an entry point file inside the `minestrapper` directory.
92
+
93
+ **Example:**
94
+ ```python
95
+ from pathlib import Path
96
+
97
+ from minestrapper import Server
98
+
99
+ if __name__ == "__main__":
100
+ server = Server(path=Path.cwd().parent)
101
+ server.start_server()
102
+
103
+ server.wait_loop()
104
+
105
+ ```
106
+
107
+ ## Important Notes
108
+ - You still need to do any necessary port forwarding yourself.
109
+
110
+ ## License
111
+ MineStrapper is released under the [MIT License](./LICENSE).
112
+ Feel free to use, modify, and share.
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "minestrapper"
3
+ version="0.1.0"
4
+ description="Minestrapper is a bootstrapper for Minecraft servers that lets you easily add custom features to your server without needing a full mod/plugin loader."
5
+ authors = [
6
+ { name = "Faizaan J" }
7
+ ]
8
+ requires-python= ">=3.9"
9
+
10
+ [tool.setuptools]
11
+ package-dir = {"" = "src"}
12
+
13
+ [tool.setuptools.packages.find]
14
+ where = ["src"]
15
+
16
+ [build-system]
17
+ requires = ["setuptools", "twine"]
18
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .server import Server
2
+ from .state_handler import ServerState
3
+
4
+ __all__ = ["Server", "ServerState"]
@@ -0,0 +1,50 @@
1
+ import os
2
+ import json
3
+ from enum import Enum
4
+
5
+ class FileType(Enum):
6
+ CONFIG = "config"
7
+ SERVER_PROPERTIES = "server_properties"
8
+
9
+ def key_value_to_dict(unpacked_string: list) -> dict:
10
+ dict = {}
11
+
12
+ for line in unpacked_string:
13
+ key_value = line.strip().split('=')
14
+ if len(key_value) == 2:
15
+ dict[key_value[0]] = key_value[1]
16
+
17
+ return dict
18
+
19
+ def dict_to_key_value(dictionary: dict) -> str:
20
+ key_value_string = ""
21
+ for key, value in dictionary.items():
22
+ key_value_string += f"{key}={value}\n"
23
+ return key_value_string
24
+
25
+ class ConfigHandler:
26
+ def __init__(self, path: str):
27
+ self.path = path
28
+ self.config_json = os.path.join(self.path, "minestrapper", "config.json")
29
+ self.server_properties_file = os.path.join(self.path, "server.properties")
30
+
31
+ def __open(self, file_type: FileType):
32
+ if file_type == FileType.CONFIG:
33
+ self.__config = json.load(open(self.config_json, 'r'))
34
+ elif file_type == FileType.SERVER_PROPERTIES:
35
+ with open(self.server_properties_file, 'r') as f:
36
+ self.__server_properties = key_value_to_dict(f.readlines())
37
+
38
+ def get_config(self):
39
+ self.__open(FileType.CONFIG)
40
+ return self.__config
41
+
42
+ def get_server_properties(self):
43
+ self.__open(FileType.SERVER_PROPERTIES)
44
+ return self.__server_properties
45
+
46
+ def set_server_properties(self, key : str, value : str):
47
+ self.__open(FileType.SERVER_PROPERTIES)
48
+ self.__server_properties[key] = value
49
+ with open(self.server_properties_file, 'w') as f:
50
+ f.write(dict_to_key_value(self.__server_properties))
@@ -0,0 +1,13 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from ..server import Server
5
+
6
+ class Feature:
7
+ def __init__(self, name: str, description: str, server: "Server"):
8
+ self.name = name
9
+ self.description = description
10
+ self.server = server
11
+
12
+ def run(self):
13
+ raise NotImplementedError("Feature must implement the run method")
@@ -0,0 +1,35 @@
1
+ import os
2
+ import sys
3
+
4
+ from typing import TYPE_CHECKING
5
+
6
+ from .state_styles.state_styles import StateStyle
7
+ from .server_resource_pack.server_resource_pack import ServerResourcePack
8
+
9
+ if TYPE_CHECKING:
10
+ from ..server import Server
11
+
12
+ FEATURES = {
13
+ "state_styles": {
14
+ "class": StateStyle,
15
+ "enabled_by_default": True
16
+ },
17
+ "server_resource_pack": {
18
+ "class": ServerResourcePack,
19
+ "enabled_by_default": False
20
+ }
21
+ }
22
+
23
+ def handle_built_in_features(server: "Server"):
24
+ for feature_key, feature_info in FEATURES.items():
25
+ target_feature = server.config_handler.get_config()["features"].get(feature_key)
26
+ if target_feature is None: continue
27
+
28
+ if target_feature.get("feature_enabled", feature_info["enabled_by_default"]):
29
+ feature_class = feature_info["class"]
30
+ feature_instance = feature_class(server)
31
+ server.logger.info(f"Feature '{feature_instance.name}' has been started.")
32
+ try:
33
+ feature_instance.run()
34
+ except Exception as e:
35
+ server.logger.error(f"An error occurred while running the feature '{feature_instance.name}': {e}")
@@ -0,0 +1,48 @@
1
+ import ipaddress
2
+ import socket
3
+
4
+ def is_valid_ip(ip_address: str) -> bool:
5
+ try:
6
+ ipaddress.ip_address(ip_address)
7
+ except ValueError:
8
+ raise ValueError(f"Invalid IP address for server resource pack: {ip_address}")
9
+
10
+ return True
11
+
12
+ def get_port_integer(port: str) -> int:
13
+ try:
14
+ return int(port)
15
+ except (TypeError, ValueError):
16
+ raise ValueError(f"Port must be an integer, got {port}")
17
+
18
+ def is_valid_port(port: int) -> bool:
19
+ if (port < 1 or port > 65535):
20
+ raise ValueError(f"Port must be between 1 and 65535, got {port}")
21
+
22
+ return True
23
+
24
+ def is_port_free(ip: str, port: str) -> bool:
25
+ candidate_port = int(port)
26
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
27
+ return s.connect_ex((ip, candidate_port)) != 0
28
+
29
+ def get_clean_address(ip: str, port: str) -> tuple[str, int]:
30
+ cleaned_ip = ip
31
+ cleaned_port = port
32
+
33
+ if (ip == "localhost"):
34
+ cleaned_ip = "127.0.0.1"
35
+ if (ip in [None, ""]):
36
+ cleaned_ip = "0.0.0.0"
37
+
38
+ if (not is_valid_ip(cleaned_ip)):
39
+ raise ValueError(f"Invalid IP address for server resource pack: {cleaned_ip}")
40
+
41
+ port_int = get_port_integer(port)
42
+ if (not is_valid_port(port_int)):
43
+ raise ValueError(f"Invalid port for server resource pack: {port}")
44
+
45
+ if (not is_port_free(cleaned_ip, port)):
46
+ cleaned_port = 0
47
+
48
+ return cleaned_ip, int(cleaned_port)
@@ -0,0 +1,26 @@
1
+ from pathlib import Path
2
+
3
+ def resolve_server_path(path_str: str, server_root: str | Path) -> Path:
4
+ if (not path_str):
5
+ raise ValueError(
6
+ "Resource pack path is not set in config."
7
+ "Did you mean to disable the server_resource_pack feature?"
8
+ )
9
+
10
+ path = Path(path_str)
11
+
12
+ if not path.is_absolute():
13
+ path = Path(server_root) / path
14
+
15
+ path = path.resolve()
16
+
17
+ if (not path.exists()):
18
+ raise ValueError(f"Resource pack path {path} does not exist.")
19
+
20
+ if (not path.is_file()):
21
+ raise ValueError(f"Resource pack path {path} is not a file.")
22
+
23
+ if (path.suffix.lower() != ".zip"):
24
+ raise ValueError(f"Resource pack path {path} is not a zip file.")
25
+
26
+ return path
@@ -0,0 +1,87 @@
1
+ import threading
2
+
3
+ import os
4
+ from http.server import BaseHTTPRequestHandler, HTTPServer
5
+
6
+ from minestrapper.logger import LogLevel
7
+
8
+ from pathlib import Path
9
+
10
+ from minestrapper.features.feature import Feature
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from minestrapper.features.server_resource_pack.path_resolver import resolve_server_path
15
+ if TYPE_CHECKING:
16
+ from ....minestrapper.server import Server
17
+
18
+ from .address_validator import get_clean_address
19
+
20
+ import hashlib
21
+
22
+ class ServerResourcePack(Feature):
23
+ def __init__(self, server: "Server"):
24
+ super().__init__(
25
+ "Server Resource Pack",
26
+ "An HTTP server to serve a custom resource pack for your Minecraft Server.",
27
+ server
28
+ )
29
+
30
+ self.config = self.server.config_handler.get_config()['features']['server_resource_pack']
31
+
32
+ self.path = resolve_server_path(self.config["path"], self.server.path)
33
+ self.ip, self.port = get_clean_address(self.config.get("ip", ""), self.config.get("port", ""))
34
+
35
+ def log(self, level: LogLevel, message: str):
36
+ self.server.logger.log(level, f"Resource Pack Server: {message}")
37
+
38
+ def get_sha1(self, file_path: Path) -> str:
39
+ sha1 = hashlib.sha1()
40
+
41
+ with open(file_path, "rb") as f:
42
+ while chunk := f.read(65536):
43
+ sha1.update(chunk)
44
+
45
+ return sha1.hexdigest()
46
+
47
+ def start_resource_pack_server(self):
48
+ file_path = self.path
49
+ server_instance = self.server
50
+ log_method = self.log
51
+
52
+ class ResourcePackRequestHandler(BaseHTTPRequestHandler):
53
+ def log_message(self, format, *args):
54
+ log_level = LogLevel.INFO
55
+ if (len(args) >= 2):
56
+ try:
57
+ code = int(args[1])
58
+ if (code >= 500):
59
+ log_level = LogLevel.ERROR
60
+ elif (code >= 400):
61
+ log_level = LogLevel.WARNING
62
+ except Exception as e:
63
+ pass
64
+ log_method(log_level, format % args)
65
+
66
+ def do_GET(self):
67
+ self.send_response(200)
68
+ self.send_header("Content-Type", "application/zip")
69
+ self.send_header("Content-Length", str(os.path.getsize(file_path)))
70
+ self.end_headers()
71
+
72
+ with open(file_path, "rb") as resource_pack_file:
73
+ while chunk := resource_pack_file.read(65536):
74
+ self.wfile.write(chunk)
75
+
76
+ server = HTTPServer((self.ip, self.port), ResourcePackRequestHandler)
77
+ threading.Thread(target=server.serve_forever, daemon=True).start()
78
+
79
+ return server
80
+
81
+ def run(self):
82
+ self.resource_pack_server = self.start_resource_pack_server()
83
+ resource_pack_url = f"http://{self.ip}:{self.port}/"
84
+
85
+ self.server.logger.info(f"Started resource pack server at {resource_pack_url}.")
86
+ self.server.config_handler.set_server_properties("resource-pack", resource_pack_url)
87
+ self.server.config_handler.set_server_properties("resource-pack-sha1", self.get_sha1(self.path))
@@ -0,0 +1,59 @@
1
+ import os
2
+ import ctypes
3
+
4
+ from typing import TYPE_CHECKING, Callable
5
+
6
+ from minestrapper.features.feature import Feature
7
+ from minestrapper.util.ansi_colors import ANSI_COLORS
8
+ from minestrapper.state_handler import ServerState
9
+
10
+ if TYPE_CHECKING:
11
+ from ....minestrapper.server import Server
12
+
13
+ class StateStyle(Feature):
14
+ def __init__(self, server: "Server"):
15
+ super().__init__("State Styles", "Provider for colored text and titles based on different server states", server)
16
+ self.state_styles_config = self.server.config_handler.get_config()['features']['state_styles']
17
+ self.styles = self.state_styles_config["styles"]
18
+ self.title_enabled = self.state_styles_config.get("title_enabled", False)
19
+ self.color_enabled = self.state_styles_config.get("color_enabled", False)
20
+
21
+ def get_colored_text(self, text: str, state: ServerState) -> str:
22
+ map = {
23
+ ServerState.STARTING: ANSI_COLORS.get(self.styles["starting"]["color"], ANSI_COLORS["reset"]),
24
+ ServerState.RUNNING: ANSI_COLORS.get(self.styles["running"]["color"], ANSI_COLORS["reset"]),
25
+ ServerState.STOPPING: ANSI_COLORS.get(self.styles["stopping"]["color"], ANSI_COLORS["reset"]),
26
+ ServerState.STOPPED: ANSI_COLORS.get(self.styles["stopped"]["color"], ANSI_COLORS["reset"]),
27
+ ServerState.PAUSED: ANSI_COLORS.get(self.styles["paused"]["color"], ANSI_COLORS["reset"]),
28
+ }
29
+
30
+ color = map.get(state, ANSI_COLORS["reset"])
31
+
32
+ return f"{color}{text}{ANSI_COLORS['reset']}"
33
+
34
+ def get_title(self, state: ServerState) -> str:
35
+ map = {
36
+ ServerState.STARTING: self.styles["starting"].get("title", "Starting"),
37
+ ServerState.RUNNING: self.styles["running"].get("title", "Running"),
38
+ ServerState.STOPPING: self.styles["stopping"].get("title", "Stopping"),
39
+ ServerState.STOPPED: self.styles["stopped"].get("title", "Stopped"),
40
+ ServerState.PAUSED: self.styles["paused"].get("title", "Paused"),
41
+ }
42
+
43
+ return map.get(state, "Unknown")
44
+
45
+ def run(self):
46
+ if (self.color_enabled):
47
+ @self.server.logger.add_line_transformer
48
+ def state_styles_transformer(line: str):
49
+ return self.get_colored_text(line, self.server.state_handler.get())
50
+
51
+ if (self.title_enabled):
52
+ @self.server.state_handler.on_state_change
53
+ def set_terminal_title():
54
+ title = self.get_title(self.server.state_handler.get())
55
+ if (os.name == "nt"):
56
+ ctypes.windll.kernel32.SetConsoleTitleW(title)
57
+ else:
58
+ sys.stdout.write(f'\x1b]0;{title}\x07')
59
+ sys.stdout.flush()
@@ -0,0 +1,76 @@
1
+ import os
2
+ import threading
3
+
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from datetime import datetime
7
+
8
+ from typing import TYPE_CHECKING, Callable
9
+
10
+ if TYPE_CHECKING:
11
+ from .server import Server
12
+
13
+ class LogLevel(Enum):
14
+ INFO = "INFO"
15
+ WARNING = "WARNING"
16
+ ERROR = "ERROR"
17
+
18
+ class Logger:
19
+ def __init__(self, server: "Server"):
20
+ self.server = server
21
+ self.line_transformers: list[Callable[[str], str]] = []
22
+
23
+ logs_folder = Path(server.path) / "logs"
24
+ logs_folder.mkdir(parents=True, exist_ok=True)
25
+ self.log_file = logs_folder / "latest.log"
26
+ self.log_file.parent.mkdir(parents=True, exist_ok=True)
27
+
28
+ self.minestrapper_log_file = logs_folder / "latest-minestrapper.log"
29
+ self.minestrapper_log_file.parent.mkdir(parents=True, exist_ok=True)
30
+ self.minestrapper_log_file.touch(exist_ok=True)
31
+ with open(self.minestrapper_log_file, "w", encoding="utf-8") as log:
32
+ log.write("")
33
+
34
+ self.info("Initialized Minestrapper Logger successfully.")
35
+
36
+ def add_line_transformer(self, transformer: Callable[[str], str]):
37
+ self.line_transformers.append(transformer)
38
+ return transformer
39
+
40
+ def __write_line_log(self, line: str):
41
+ with open(self.minestrapper_log_file, "a", encoding="utf-8") as f:
42
+ f.write(line + "\n")
43
+
44
+ def print_with_transformation(self, message: str):
45
+ for transformer in self.line_transformers:
46
+ message = transformer(message)
47
+
48
+ print(message)
49
+
50
+ def forwarded_log(self, message: str):
51
+ self.print_with_transformation(message)
52
+ self.__write_line_log(message)
53
+
54
+ def log(self, level: LogLevel, message: str):
55
+ timestamp = f"[{datetime.now().strftime('%H:%M:%S')}]"
56
+ log_level_label = f"[Minestrapper/{level.value}]:"
57
+ full_line = f"{timestamp} {log_level_label} {message}"
58
+
59
+ self.print_with_transformation(full_line)
60
+ self.__write_line_log(full_line)
61
+
62
+ def publish_minestrapper_log(self):
63
+ with open(self.minestrapper_log_file, "r", encoding="utf-8") as f:
64
+ minestrapper_log_content = f.read()
65
+
66
+ with open(self.log_file, "w", encoding="utf-8") as f:
67
+ f.write(minestrapper_log_content)
68
+
69
+ def info(self, message: str):
70
+ self.log(LogLevel.INFO, message)
71
+
72
+ def warning(self, message: str):
73
+ self.log(LogLevel.WARNING, message)
74
+
75
+ def error(self, message: str):
76
+ self.log(LogLevel.ERROR, message)
@@ -0,0 +1,124 @@
1
+ from pathlib import Path
2
+ import subprocess
3
+ from typing import Callable
4
+ import threading
5
+
6
+ import os
7
+
8
+ from .logger import Logger
9
+
10
+ from .config_handler import ConfigHandler
11
+ from .state_handler import ServerState, StateHandler
12
+
13
+ from minestrapper.util.get_state_from_line import get_state_from_line
14
+
15
+ from minestrapper.features.handle_built_in_features import handle_built_in_features
16
+
17
+ from time import sleep as wait
18
+
19
+ class Server:
20
+ def __init__(self, path: str | Path):
21
+ self.path : str = (str(path) if isinstance(path, Path) else path)
22
+ self.config_handler : ConfigHandler = ConfigHandler(self.path)
23
+ self.state_handler : StateHandler = StateHandler(ServerState.STARTING)
24
+ self.periodic_callbacks : list[Callable] = []
25
+ self.new_line_callbacks : list[Callable] = []
26
+ self.server_process : subprocess.Popen | None = None
27
+
28
+ self.logger = Logger(self)
29
+
30
+ os.chdir(self.path)
31
+
32
+ def __start_server_process(self):
33
+ config = self.config_handler.get_config()
34
+
35
+ jar_path = f"{config['server']['jar_name']}.jar"
36
+
37
+ full_command = [
38
+ "java"
39
+ ]
40
+ java_args = [
41
+ f"-Xms{config['server']['min_ram']}",
42
+ f"-Xmx{config['server']['max_ram']}",
43
+ ]
44
+
45
+ if "other_args" in config['server']:
46
+ java_args.extend(config['server']['other_args'])
47
+
48
+ full_command.extend(java_args)
49
+ full_command.extend([
50
+ "-jar", jar_path, "nogui"
51
+ ])
52
+
53
+ server_process = subprocess.Popen(
54
+ full_command,
55
+ stdout=subprocess.PIPE,
56
+ stderr=subprocess.STDOUT,
57
+ text=True,
58
+ bufsize=1,
59
+ encoding="utf-8",
60
+ errors="replace"
61
+ )
62
+
63
+ return server_process
64
+
65
+ def start_server(self):
66
+ self.server_process = self.__start_server_process()
67
+
68
+ def loop():
69
+ while self.state_handler.get() != ServerState.STOPPED and self.server_process is not None and self.server_process.poll() is None:
70
+ self.__on_periodic()
71
+
72
+ if (self.periodic_callbacks):
73
+ for callback in self.periodic_callbacks:
74
+ callback()
75
+ wait(0.020)
76
+
77
+ self.periodic_thread = threading.Thread(target=loop, daemon=True)
78
+ self.periodic_thread.start()
79
+
80
+ def output_loop():
81
+ assert self.server_process is not None
82
+ if (self.server_process.stdout is not None):
83
+ for line in self.server_process.stdout:
84
+ self.__on_new_line(line.strip("\n"))
85
+
86
+ self.output_thread = threading.Thread(target=output_loop, daemon=True)
87
+ self.output_thread.start()
88
+
89
+ self.logger.info("Server process started successfully.")
90
+ handle_built_in_features(self)
91
+
92
+ def wait_loop(self):
93
+ assert self.server_process is not None
94
+ self.server_process.wait()
95
+
96
+ # If the server process has stopped, this code should now run.
97
+ self.state_handler.set(ServerState.STOPPED)
98
+ self.logger.info("Server process has stopped.")
99
+ self.logger.publish_minestrapper_log()
100
+ input("Press Enter to exit...")
101
+
102
+ def __on_new_line(self, line : str):
103
+ new_state = get_state_from_line(line)
104
+ if (new_state is not None and new_state != self.state_handler.get()):
105
+ self.state_handler.set(new_state)
106
+
107
+ self.logger.forwarded_log(line)
108
+
109
+ if self.new_line_callbacks:
110
+ for callback in self.new_line_callbacks:
111
+ callback(line)
112
+
113
+ def on_new_line(self, function: Callable):
114
+ self.new_line_callbacks.append(function)
115
+ return function
116
+
117
+ def __on_periodic(self):
118
+ for callback in self.periodic_callbacks:
119
+ callback()
120
+
121
+ def on_periodic(self, function: Callable):
122
+ self.periodic_callbacks.append(function)
123
+
124
+ return function
@@ -0,0 +1,44 @@
1
+ from enum import Enum
2
+ from typing import Callable
3
+
4
+ class ServerState(Enum):
5
+ STARTING = "starting"
6
+ RUNNING = "running"
7
+ PAUSED = "paused"
8
+ STOPPING = "stopping"
9
+ STOPPED = "stopped"
10
+
11
+ class StateHandler:
12
+ def __init__(self, initial_state: ServerState):
13
+ self.__server_state = initial_state
14
+ self.state_callbacks: dict[ServerState | None, list[Callable]] = {}
15
+
16
+ def set(self, state: ServerState):
17
+ if (state == self.__server_state):
18
+ return
19
+
20
+ self.__server_state = state
21
+
22
+ for callback in self.state_callbacks.get(state, []):
23
+ callback()
24
+
25
+ for callback in self.state_callbacks.get(None, []):
26
+ callback()
27
+
28
+ def on_state_change(self, function: Callable, state: ServerState | None = None) -> Callable:
29
+ def decorator():
30
+ if state is None or self.get() == state:
31
+ function()
32
+
33
+ if (state is not None and not isinstance(state, ServerState)):
34
+ raise NameError(f"State {state} is not a valid server state.")
35
+
36
+ if state not in self.state_callbacks:
37
+ self.state_callbacks[state] = []
38
+ self.state_callbacks[state].append(function)
39
+
40
+ decorator() # Call the function immediately if we're already in the correct state
41
+ return decorator
42
+
43
+ def get(self) -> ServerState:
44
+ return self.__server_state
File without changes
@@ -0,0 +1,19 @@
1
+ ANSI_COLORS = {
2
+ "reset": "\033[0m",
3
+ "red": "\033[31m",
4
+ "bright_red": "\033[91m",
5
+ "green": "\033[32m",
6
+ "bright_green": "\033[92m",
7
+ "yellow": "\033[33m",
8
+ "bright_yellow": "\033[93m",
9
+ "blue": "\033[34m",
10
+ "bright_blue": "\033[94m",
11
+ "magenta": "\033[35m",
12
+ "bright_magenta": "\033[95m",
13
+ "cyan": "\033[36m",
14
+ "bright_cyan": "\033[96m",
15
+ "white": "\033[37m",
16
+ "bright_white": "\033[97m",
17
+ "black": "\033[30m",
18
+ "bright_black": "\033[90m",
19
+ }
@@ -0,0 +1,30 @@
1
+ import re
2
+ from ..state_handler import ServerState
3
+
4
+ # These regex statements were vibe coded 🤤:
5
+ # "[HH:MM:SS] [Thread/Level]:"
6
+ LOG_PREFIX = re.compile(r"^\[\d{2}:\d{2}:\d{2}\] \[[^\]]+\]: ")
7
+ # "Playername joined the game"
8
+ PLAYER_JOINED_REGEX = r"^[^\s]+ joined the game"
9
+
10
+ def __strip_prefix(line: str) -> str:
11
+ return LOG_PREFIX.sub("", line, count=1)
12
+
13
+ def get_state_from_line(line: str):
14
+ msg = __strip_prefix(line)
15
+
16
+ state_table = {
17
+ msg.startswith("Done ("): ServerState.RUNNING,
18
+ msg.startswith("Stopping the server"): ServerState.STOPPING,
19
+ msg.startswith("Server empty for 60 seconds, pausing"): ServerState.PAUSED,
20
+ re.match(PLAYER_JOINED_REGEX, msg): ServerState.RUNNING
21
+ }
22
+
23
+ new_state = None
24
+ for condition in state_table:
25
+ new_state_value = state_table[condition]
26
+ if condition:
27
+ new_state = new_state_value
28
+ break
29
+
30
+ return new_state
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: minestrapper
3
+ Version: 0.1.0
4
+ Summary: Minestrapper is a bootstrapper for Minecraft servers that lets you easily add custom features to your server without needing a full mod/plugin loader.
5
+ Author: Faizaan J
6
+ Requires-Python: >=3.9
7
+ License-File: LICENSE
8
+ Dynamic: license-file
@@ -0,0 +1,24 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/minestrapper/__init__.py
5
+ src/minestrapper/config_handler.py
6
+ src/minestrapper/logger.py
7
+ src/minestrapper/server.py
8
+ src/minestrapper/state_handler.py
9
+ src/minestrapper.egg-info/PKG-INFO
10
+ src/minestrapper.egg-info/SOURCES.txt
11
+ src/minestrapper.egg-info/dependency_links.txt
12
+ src/minestrapper.egg-info/top_level.txt
13
+ src/minestrapper/features/__init__.py
14
+ src/minestrapper/features/feature.py
15
+ src/minestrapper/features/handle_built_in_features.py
16
+ src/minestrapper/features/server_resource_pack/__init__.py
17
+ src/minestrapper/features/server_resource_pack/address_validator.py
18
+ src/minestrapper/features/server_resource_pack/path_resolver.py
19
+ src/minestrapper/features/server_resource_pack/server_resource_pack.py
20
+ src/minestrapper/features/state_styles/__init__.py
21
+ src/minestrapper/features/state_styles/state_styles.py
22
+ src/minestrapper/util/__init__.py
23
+ src/minestrapper/util/ansi_colors.py
24
+ src/minestrapper/util/get_state_from_line.py
@@ -0,0 +1 @@
1
+ minestrapper