modsmith 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,359 @@
1
+ """Read Forge and NeoForge installer jars and expand their variables."""
2
+
3
+ import json
4
+ import zipfile
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
9
+
10
+ from ..errors import LoaderError, UnsupportedError
11
+ from ..minecraft import maven_path
12
+
13
+
14
+ class _Artifact(BaseModel):
15
+ """A library's ``downloads.artifact`` object."""
16
+
17
+ path: str | None = None
18
+ url: str | None = None
19
+ sha1: str | None = None
20
+ size: int | None = None
21
+
22
+
23
+ class _Downloads(BaseModel):
24
+ """A library's ``downloads`` object."""
25
+
26
+ artifact: _Artifact | None = None
27
+
28
+
29
+ class Library(BaseModel):
30
+ """A library listed by an installer.
31
+
32
+ Attributes:
33
+ name: Its Maven coordinate.
34
+ """
35
+
36
+ model_config = ConfigDict(frozen=True)
37
+
38
+ name: str
39
+ downloads: _Downloads | None = None
40
+
41
+ @property
42
+ def _artifact(self) -> _Artifact:
43
+ """The download details, or an empty artifact if there are none."""
44
+ if self.downloads and self.downloads.artifact:
45
+ return self.downloads.artifact
46
+ return _Artifact()
47
+
48
+ @property
49
+ def path(self) -> str:
50
+ """Its path inside the libraries directory."""
51
+ return self._artifact.path or maven_path(self.name)
52
+
53
+ @property
54
+ def url(self) -> str | None:
55
+ """Where to download it, or None if it has no URL."""
56
+ return self._artifact.url or None
57
+
58
+ @property
59
+ def hashes(self) -> dict[str, str]:
60
+ """Its expected digests keyed by algorithm name."""
61
+ sha1 = self._artifact.sha1
62
+ return {"sha1": sha1} if sha1 else {}
63
+
64
+ @property
65
+ def size(self) -> int | None:
66
+ """Its size in bytes, or None if unknown."""
67
+ return self._artifact.size or None
68
+
69
+
70
+ class Processor(BaseModel):
71
+ """One step of an install: a jar to run and the files it produces.
72
+
73
+ Attributes:
74
+ jar: The Maven coordinate of the jar to run.
75
+ classpath: Maven coordinates of the jar's dependencies.
76
+ args: Command-line arguments, which can contain variables.
77
+ outputs: Expected SHA-1 digests keyed by output file.
78
+ sides: The sides it runs on, or None for every side.
79
+ """
80
+
81
+ model_config = ConfigDict(frozen=True)
82
+
83
+ jar: str
84
+ classpath: list[str] = Field(default_factory=list[str])
85
+ args: list[str] = Field(default_factory=list[str])
86
+ outputs: dict[str, str] = Field(default_factory=dict[str, str])
87
+ sides: list[str] | None = None
88
+
89
+ def applies_to(self, side: str) -> bool:
90
+ """Return whether this step runs on ``side``."""
91
+ return self.sides is None or side in self.sides
92
+
93
+
94
+ class _DataValue(BaseModel):
95
+ """A variable's value for each side."""
96
+
97
+ client: str
98
+ server: str = ""
99
+
100
+
101
+ class InstallProfile(BaseModel):
102
+ """The ``install_profile.json`` inside a Forge or NeoForge installer.
103
+
104
+ Attributes:
105
+ spec: The profile format version.
106
+ version: The version id it installs.
107
+ minecraft: The Minecraft version it targets.
108
+ json_path: The path of the version JSON inside the installer.
109
+ libraries: The libraries the processors need.
110
+ processors: The install steps, in order.
111
+ data: The variables available to the processors.
112
+ """
113
+
114
+ model_config = ConfigDict(frozen=True, populate_by_name=True)
115
+
116
+ spec: int
117
+ version: str
118
+ minecraft: str
119
+ json_path: str = Field(alias="json")
120
+ libraries: list[Library] = Field(default_factory=list[Library])
121
+ processors: list[Processor] = Field(default_factory=list[Processor])
122
+ data: dict[str, _DataValue] = Field(default_factory=dict[str, _DataValue])
123
+
124
+ @classmethod
125
+ def parse(cls, data: dict[str, Any]) -> "InstallProfile":
126
+ """Validate a decoded profile.
127
+
128
+ Args:
129
+ data: The decoded JSON.
130
+
131
+ Returns:
132
+ The profile.
133
+
134
+ Raises:
135
+ UnsupportedError: The profile is from before Minecraft 1.13.
136
+ """
137
+ if "spec" not in data:
138
+ raise UnsupportedError("Installers before Minecraft 1.13 are not supported")
139
+ return cls.model_validate(data)
140
+
141
+ def client_processors(self) -> list[Processor]:
142
+ """Return the steps that run on the client."""
143
+ return [p for p in self.processors if p.applies_to("client")]
144
+
145
+
146
+ class InstallerJar:
147
+ """A downloaded Forge or NeoForge installer, read as an archive.
148
+
149
+ Args:
150
+ path: The installer jar.
151
+
152
+ Attributes:
153
+ path: The installer jar.
154
+ profile: Its install profile.
155
+ version_json: Its version JSON.
156
+
157
+ Raises:
158
+ LoaderError: The installer is malformed.
159
+ UnsupportedError: The installer is older than Minecraft 1.13.
160
+ """
161
+
162
+ def __init__(self, path: Path) -> None:
163
+ self.path = path
164
+ try:
165
+ with zipfile.ZipFile(path) as archive:
166
+ self.profile = InstallProfile.parse(
167
+ json.loads(archive.read("install_profile.json"))
168
+ )
169
+ self.version_json: dict[str, Any] = json.loads(
170
+ archive.read(self.profile.json_path.lstrip("/"))
171
+ )
172
+ except (zipfile.BadZipFile, KeyError, ValueError, ValidationError) as e:
173
+ raise LoaderError(f"Malformed installer {path.name}: {e}") from e
174
+
175
+ @property
176
+ def version_id(self) -> str:
177
+ """The id declared by the version JSON."""
178
+ return self.version_json["id"]
179
+
180
+ @property
181
+ def version_libraries(self) -> list[Library]:
182
+ """The libraries the version JSON lists for the running game."""
183
+ items = self.version_json.get("libraries", [])
184
+ return [Library.model_validate(item) for item in items]
185
+
186
+ def extract_bundled_libraries(self, libraries_dir: Path) -> None:
187
+ """Copy the libraries bundled in the installer into ``libraries_dir``.
188
+
189
+ Args:
190
+ libraries_dir: The game's libraries directory.
191
+ """
192
+ with zipfile.ZipFile(self.path) as archive:
193
+ for info in archive.infolist():
194
+ if info.is_dir() or not info.filename.startswith("maven/"):
195
+ continue
196
+ dest = libraries_dir / info.filename.removeprefix("maven/")
197
+ if dest.is_file() and dest.stat().st_size == info.file_size:
198
+ continue
199
+ dest.parent.mkdir(parents=True, exist_ok=True)
200
+ dest.write_bytes(archive.read(info))
201
+
202
+ def extract_data_file(self, name: str, dest_dir: Path) -> Path:
203
+ """Extract a file named by the install profile.
204
+
205
+ Args:
206
+ name: The file's path inside the installer.
207
+ dest_dir: The directory to extract into.
208
+
209
+ Returns:
210
+ The extracted file.
211
+
212
+ Raises:
213
+ LoaderError: The installer does not contain the file.
214
+ """
215
+ relative = name.lstrip("/")
216
+ dest = dest_dir / relative
217
+ dest.parent.mkdir(parents=True, exist_ok=True)
218
+ try:
219
+ with zipfile.ZipFile(self.path) as archive:
220
+ dest.write_bytes(archive.read(relative))
221
+ except KeyError as e:
222
+ raise LoaderError(f"Installer is missing {name}") from e
223
+ return dest
224
+
225
+
226
+ def replace_tokens(tokens: dict[str, str], value: str) -> str:
227
+ """Expand ``{NAME}`` and ``'literal'`` as the installer format defines them.
228
+
229
+ Args:
230
+ tokens: The variables.
231
+ value: The text to expand.
232
+
233
+ Returns:
234
+ The expanded text.
235
+
236
+ Raises:
237
+ LoaderError: The text is malformed or uses an unknown variable.
238
+ """
239
+ result: list[str] = []
240
+ closers = {"{": "}", "'": "'"}
241
+ index = 0
242
+
243
+ def escaped() -> str:
244
+ nonlocal index
245
+ if index == len(value):
246
+ raise LoaderError(f"Bad escape in pattern: {value}")
247
+ char = value[index]
248
+ index += 1
249
+ return char
250
+
251
+ while index < len(value):
252
+ char = value[index]
253
+ index += 1
254
+ if char == "\\":
255
+ result.append(escaped())
256
+ elif char in closers:
257
+ key: list[str] = []
258
+ while True:
259
+ if index == len(value):
260
+ raise LoaderError(f"Unclosed {char} in pattern: {value}")
261
+ inner = value[index]
262
+ index += 1
263
+ if inner == "\\":
264
+ key.append(escaped())
265
+ elif inner == closers[char]:
266
+ break
267
+ else:
268
+ key.append(inner)
269
+ name = "".join(key)
270
+ if char == "'":
271
+ result.append(name)
272
+ elif name in tokens:
273
+ result.append(tokens[name])
274
+ else:
275
+ raise LoaderError(f"Unknown variable {name} in pattern: {value}")
276
+ else:
277
+ result.append(char)
278
+ return "".join(result)
279
+
280
+
281
+ class Variables:
282
+ """The variables available to a processor.
283
+
284
+ Args:
285
+ libraries_dir: The game's libraries directory.
286
+ tokens: The values keyed by variable name.
287
+
288
+ Attributes:
289
+ libraries_dir: The game's libraries directory.
290
+ tokens: The values keyed by variable name.
291
+ """
292
+
293
+ def __init__(self, libraries_dir: Path, tokens: dict[str, str]) -> None:
294
+ self.libraries_dir = libraries_dir
295
+ self.tokens = tokens
296
+
297
+ @classmethod
298
+ def build(
299
+ cls,
300
+ installer: InstallerJar,
301
+ libraries_dir: Path,
302
+ root: Path,
303
+ minecraft_jar: Path,
304
+ temp_dir: Path,
305
+ ) -> "Variables":
306
+ """Build the variables for an install, extracting the files they refer to.
307
+
308
+ Args:
309
+ installer: The installer.
310
+ libraries_dir: The game's libraries directory.
311
+ root: The game directory.
312
+ minecraft_jar: The vanilla client jar.
313
+ temp_dir: Where to extract data files.
314
+
315
+ Returns:
316
+ The variables.
317
+
318
+ Raises:
319
+ LoaderError: The installer does not contain a file it refers to.
320
+ """
321
+ variables = cls(libraries_dir, {})
322
+ for key, data in installer.profile.data.items():
323
+ value = data.client
324
+ if value.startswith("[") and value.endswith("]"):
325
+ resolved = str(variables.artifact(value))
326
+ elif value.startswith("'") and value.endswith("'"):
327
+ resolved = value[1:-1]
328
+ else:
329
+ resolved = str(installer.extract_data_file(value, temp_dir))
330
+ variables.tokens[key] = resolved
331
+ variables.tokens.update(
332
+ SIDE="client",
333
+ MINECRAFT_JAR=str(minecraft_jar),
334
+ MINECRAFT_VERSION=installer.profile.minecraft,
335
+ ROOT=str(root),
336
+ INSTALLER=str(installer.path),
337
+ LIBRARY_DIR=str(libraries_dir),
338
+ )
339
+ return variables
340
+
341
+ def artifact(self, bracketed: str) -> Path:
342
+ """Return the path of a ``[group:artifact:version]`` reference."""
343
+ return self.libraries_dir / maven_path(bracketed[1:-1])
344
+
345
+ def resolve(self, value: str) -> str:
346
+ """Expand a processor value.
347
+
348
+ Args:
349
+ value: A ``[coordinate]`` reference, or text with variables.
350
+
351
+ Returns:
352
+ The expanded value.
353
+
354
+ Raises:
355
+ LoaderError: The text is malformed or uses an unknown variable.
356
+ """
357
+ if value.startswith("[") and value.endswith("]"):
358
+ return str(self.artifact(value))
359
+ return replace_tokens(self.tokens, value)
@@ -0,0 +1,223 @@
1
+ """Run the processors of a Forge or NeoForge installer."""
2
+
3
+ import logging
4
+ import os
5
+ import subprocess
6
+ import tempfile
7
+ import zipfile
8
+ import zlib
9
+ from pathlib import Path
10
+
11
+ from ..errors import Cancelled, ProcessorError
12
+ from ..minecraft import maven_path
13
+ from ..net import file_hash
14
+ from ..progress import CancelToken, ProgressCallback, report
15
+ from ._install_profile import Processor, Variables
16
+
17
+ logger = logging.getLogger(__name__)
18
+ TASK = "processors"
19
+ POLL_SECONDS = 0.2
20
+
21
+
22
+ def uses_zlib_ng() -> bool:
23
+ """Return whether this system's zlib is zlib-ng.
24
+
25
+ Its compressed output can differ from stock zlib, so a valid patched jar may
26
+ not match its published hash.
27
+ """
28
+ return "ng" in zlib.ZLIB_RUNTIME_VERSION.lower()
29
+
30
+
31
+ def main_class(jar: Path) -> str:
32
+ """Read a jar's ``Main-Class``.
33
+
34
+ Raises:
35
+ ProcessorError: The jar or its manifest cannot be read, or has no main class.
36
+ """
37
+ try:
38
+ with zipfile.ZipFile(jar) as archive:
39
+ manifest = archive.read("META-INF/MANIFEST.MF").decode("utf-8")
40
+ except (OSError, KeyError, zipfile.BadZipFile) as e:
41
+ raise ProcessorError(f"Cannot read manifest of {jar.name}: {e}") from e
42
+ for line in manifest.splitlines():
43
+ if line.startswith("Main-Class:"):
44
+ return line.split(":", 1)[1].strip()
45
+ raise ProcessorError(f"{jar.name} has no Main-Class")
46
+
47
+
48
+ def resolve_outputs(processor: Processor, variables: Variables) -> dict[Path, str]:
49
+ """Return a processor's output files and their expected SHA-1."""
50
+ return {
51
+ Path(variables.resolve(key)): variables.resolve(value).lower()
52
+ for key, value in processor.outputs.items()
53
+ }
54
+
55
+
56
+ def outputs_valid(outputs: dict[Path, str]) -> bool:
57
+ """Return whether every output already exists with the right hash.
58
+
59
+ Files with the wrong hash are deleted so the processor can rebuild them. With
60
+ no declared outputs this is False, because nothing can prove the work is done.
61
+ """
62
+ if not outputs:
63
+ return False
64
+ valid = True
65
+ for path, expected in outputs.items():
66
+ if not path.is_file():
67
+ valid = False
68
+ elif file_hash(path) != expected:
69
+ path.unlink()
70
+ valid = False
71
+ return valid
72
+
73
+
74
+ def is_valid_jar(path: Path) -> bool:
75
+ """Return whether a file is a zip archive with intact contents."""
76
+ try:
77
+ with zipfile.ZipFile(path) as archive:
78
+ return archive.testzip() is None
79
+ except zipfile.BadZipFile:
80
+ return False
81
+
82
+
83
+ def verify_outputs(outputs: dict[Path, str], tolerant: bool) -> None:
84
+ """Check the files a processor produced.
85
+
86
+ Args:
87
+ outputs: Expected SHA-1 digests keyed by output file.
88
+ tolerant: Accept a valid jar with a different hash.
89
+
90
+ Raises:
91
+ ProcessorError: A file is missing or wrong. A wrong file is deleted.
92
+ """
93
+ for path, expected in outputs.items():
94
+ if not path.is_file():
95
+ raise ProcessorError(f"Processor did not produce {path.name}")
96
+ actual = file_hash(path)
97
+ if actual == expected:
98
+ continue
99
+ if tolerant and path.suffix == ".jar" and is_valid_jar(path):
100
+ logger.info("Ignoring hash mismatch for %s under zlib-ng", path.name)
101
+ continue
102
+ path.unlink()
103
+ raise ProcessorError(
104
+ f"Hash mismatch for {path.name}: expected {expected}, got {actual}"
105
+ )
106
+
107
+
108
+ def build_command(
109
+ processor: Processor, variables: Variables, java: Path, libraries_dir: Path
110
+ ) -> list[str]:
111
+ """Build the command line that runs a processor.
112
+
113
+ Args:
114
+ processor: The processor.
115
+ variables: The install's variables.
116
+ java: The Java binary.
117
+ libraries_dir: The game's libraries directory.
118
+
119
+ Returns:
120
+ The command and its arguments.
121
+
122
+ Raises:
123
+ ProcessorError: The jar or a dependency is missing.
124
+ """
125
+ jar = libraries_dir / maven_path(processor.jar)
126
+ if not jar.is_file():
127
+ raise ProcessorError(f"Missing processor jar: {jar.name}")
128
+ classpath = [jar]
129
+ for coord in processor.classpath:
130
+ path = libraries_dir / maven_path(coord)
131
+ if not path.is_file():
132
+ raise ProcessorError(f"Missing processor dependency: {path.name}")
133
+ classpath.append(path)
134
+ return [
135
+ str(java),
136
+ "-cp",
137
+ os.pathsep.join(map(str, classpath)),
138
+ main_class(jar),
139
+ *[variables.resolve(arg) for arg in processor.args],
140
+ ]
141
+
142
+
143
+ def execute(
144
+ command: list[str], cwd: str, cancel: CancelToken | None
145
+ ) -> tuple[int, str]:
146
+ """Run a command and wait for it, stopping it if ``cancel`` is triggered.
147
+
148
+ Args:
149
+ command: The command and its arguments.
150
+ cwd: The working directory.
151
+ cancel: Terminates the process.
152
+
153
+ Returns:
154
+ The exit code and the combined output.
155
+
156
+ Raises:
157
+ ProcessorError: The command could not be started.
158
+ Cancelled: ``cancel`` was triggered.
159
+ """
160
+ try:
161
+ process = subprocess.Popen(
162
+ command,
163
+ cwd=cwd,
164
+ stdout=subprocess.PIPE,
165
+ stderr=subprocess.STDOUT,
166
+ text=True,
167
+ encoding="utf-8",
168
+ errors="replace",
169
+ )
170
+ except OSError as e:
171
+ raise ProcessorError(f"Cannot run {command[0]}: {e}") from e
172
+ while True:
173
+ try:
174
+ output, _ = process.communicate(timeout=POLL_SECONDS)
175
+ return process.returncode, output or ""
176
+ except subprocess.TimeoutExpired:
177
+ if cancel is not None and cancel.cancelled:
178
+ process.terminate()
179
+ process.communicate()
180
+ raise Cancelled("Operation cancelled") from None
181
+
182
+
183
+ def run_processors(
184
+ processors: list[Processor],
185
+ variables: Variables,
186
+ java: Path,
187
+ libraries_dir: Path,
188
+ progress: ProgressCallback | None = None,
189
+ cancel: CancelToken | None = None,
190
+ ) -> None:
191
+ """Run processors in order, skipping those whose outputs are already valid.
192
+
193
+ Each one runs with a temporary working directory.
194
+
195
+ Args:
196
+ processors: The processors.
197
+ variables: The install's variables.
198
+ java: The Java binary.
199
+ libraries_dir: The game's libraries directory.
200
+ progress: Receives one update for each step.
201
+ cancel: Stops the run and terminates the current processor.
202
+
203
+ Raises:
204
+ ProcessorError: A processor failed or produced a wrong file.
205
+ Cancelled: ``cancel`` was triggered.
206
+ """
207
+ tolerant = uses_zlib_ng()
208
+ total = len(processors)
209
+ with tempfile.TemporaryDirectory(prefix="modsmith-processor-") as work_dir:
210
+ for index, processor in enumerate(processors):
211
+ if cancel is not None:
212
+ cancel.check()
213
+ report(progress, TASK, index, total, "step")
214
+ outputs = resolve_outputs(processor, variables)
215
+ if outputs_valid(outputs):
216
+ continue
217
+ command = build_command(processor, variables, java, libraries_dir)
218
+ code, output = execute(command, work_dir, cancel)
219
+ if code != 0:
220
+ tail = "\n".join(output.splitlines()[-20:])
221
+ raise ProcessorError(f"{processor.jar} exited with code {code}\n{tail}")
222
+ verify_outputs(outputs, tolerant)
223
+ report(progress, TASK, total, total, "step")
@@ -0,0 +1,15 @@
1
+ """Game directories, version metadata and vanilla game files."""
2
+
3
+ from ._game_dir import GameDir, Version
4
+ from ._maven import MavenCoord, maven_path
5
+ from ._remote import VersionInfo, install_version, list_versions
6
+
7
+ __all__ = [
8
+ "GameDir",
9
+ "MavenCoord",
10
+ "Version",
11
+ "VersionInfo",
12
+ "install_version",
13
+ "list_versions",
14
+ "maven_path",
15
+ ]