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.
- modsmith/__init__.py +36 -0
- modsmith/_support.py +81 -0
- modsmith/errors.py +115 -0
- modsmith/instances.py +524 -0
- modsmith/java.py +294 -0
- modsmith/launcher.py +256 -0
- modsmith/loaders/__init__.py +23 -0
- modsmith/loaders/_base.py +157 -0
- modsmith/loaders/_fabric.py +166 -0
- modsmith/loaders/_forge.py +312 -0
- modsmith/loaders/_install_profile.py +359 -0
- modsmith/loaders/_processors.py +223 -0
- modsmith/minecraft/__init__.py +15 -0
- modsmith/minecraft/_game_dir.py +168 -0
- modsmith/minecraft/_maven.py +75 -0
- modsmith/minecraft/_remote.py +115 -0
- modsmith/net/__init__.py +7 -0
- modsmith/net/_download.py +243 -0
- modsmith/net/_resource.py +118 -0
- modsmith/net/_session.py +241 -0
- modsmith/packs/__init__.py +18 -0
- modsmith/packs/_formats.py +262 -0
- modsmith/packs/_model.py +247 -0
- modsmith/platforms/__init__.py +25 -0
- modsmith/platforms/_base.py +312 -0
- modsmith/platforms/_curseforge.py +390 -0
- modsmith/platforms/_modrinth.py +261 -0
- modsmith/progress.py +78 -0
- modsmith/py.typed +0 -0
- modsmith-0.1.0.dist-info/METADATA +285 -0
- modsmith-0.1.0.dist-info/RECORD +33 -0
- modsmith-0.1.0.dist-info/WHEEL +4 -0
- modsmith-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|