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.
- minestrapper-0.1.0/LICENSE +21 -0
- minestrapper-0.1.0/PKG-INFO +8 -0
- minestrapper-0.1.0/README.md +112 -0
- minestrapper-0.1.0/pyproject.toml +18 -0
- minestrapper-0.1.0/setup.cfg +4 -0
- minestrapper-0.1.0/src/minestrapper/__init__.py +4 -0
- minestrapper-0.1.0/src/minestrapper/config_handler.py +50 -0
- minestrapper-0.1.0/src/minestrapper/features/__init__.py +0 -0
- minestrapper-0.1.0/src/minestrapper/features/feature.py +13 -0
- minestrapper-0.1.0/src/minestrapper/features/handle_built_in_features.py +35 -0
- minestrapper-0.1.0/src/minestrapper/features/server_resource_pack/__init__.py +0 -0
- minestrapper-0.1.0/src/minestrapper/features/server_resource_pack/address_validator.py +48 -0
- minestrapper-0.1.0/src/minestrapper/features/server_resource_pack/path_resolver.py +26 -0
- minestrapper-0.1.0/src/minestrapper/features/server_resource_pack/server_resource_pack.py +87 -0
- minestrapper-0.1.0/src/minestrapper/features/state_styles/__init__.py +0 -0
- minestrapper-0.1.0/src/minestrapper/features/state_styles/state_styles.py +59 -0
- minestrapper-0.1.0/src/minestrapper/logger.py +76 -0
- minestrapper-0.1.0/src/minestrapper/server.py +124 -0
- minestrapper-0.1.0/src/minestrapper/state_handler.py +44 -0
- minestrapper-0.1.0/src/minestrapper/util/__init__.py +0 -0
- minestrapper-0.1.0/src/minestrapper/util/ansi_colors.py +19 -0
- minestrapper-0.1.0/src/minestrapper/util/get_state_from_line.py +30 -0
- minestrapper-0.1.0/src/minestrapper.egg-info/PKG-INFO +8 -0
- minestrapper-0.1.0/src/minestrapper.egg-info/SOURCES.txt +24 -0
- minestrapper-0.1.0/src/minestrapper.egg-info/dependency_links.txt +1 -0
- 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,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))
|
|
File without changes
|
|
@@ -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}")
|
|
File without changes
|
|
@@ -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))
|
|
File without changes
|
|
@@ -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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
minestrapper
|