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,157 @@
1
+ """The loader interface and the registry behind `create` and `parse`."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import ClassVar
7
+
8
+ from ..errors import UnsupportedError
9
+ from ..minecraft import GameDir
10
+ from ..net import Session
11
+ from ..progress import CancelToken, ProgressCallback
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Loader(ABC):
16
+ """A specific mod loader version. Subclass it to support another loader.
17
+
18
+ Attributes:
19
+ version: The loader version.
20
+ minecraft: The Minecraft version it targets.
21
+ kind: The name it is registered under, such as ``neoforge``.
22
+ """
23
+
24
+ version: str
25
+ minecraft: str
26
+ kind: ClassVar[str]
27
+
28
+ @abstractmethod
29
+ def version_id(
30
+ self, *, session: Session | None = None, cache_dir: Path | None = None
31
+ ) -> str:
32
+ """Return the id this loader installs under in the ``versions`` directory.
33
+
34
+ Args:
35
+ session: The session to use. A temporary one is created if omitted.
36
+ cache_dir: The cache directory. Defaults to the per-user cache.
37
+
38
+ Returns:
39
+ The version id.
40
+ """
41
+
42
+ @abstractmethod
43
+ def install(
44
+ self,
45
+ game: GameDir,
46
+ *,
47
+ java: Path | None = None,
48
+ cache_dir: Path | None = None,
49
+ session: Session | None = None,
50
+ progress: ProgressCallback | None = None,
51
+ cancel: CancelToken | None = None,
52
+ ) -> str:
53
+ """Install into ``game`` if needed and return the version id.
54
+
55
+ Args:
56
+ game: The game directory.
57
+ java: The Java binary to use, if the loader needs one.
58
+ cache_dir: The cache directory. Defaults to the per-user cache.
59
+ session: The session to use. A temporary one is created if omitted.
60
+ progress: Receives updates for each stage.
61
+ cancel: Stops the install.
62
+
63
+ Returns:
64
+ The installed version id.
65
+ """
66
+
67
+ def expected_id(self) -> str | None:
68
+ """Return the version id this loader should have, without any network use.
69
+
70
+ Returns:
71
+ The version id, or None if it cannot be known in advance. That is
72
+ the default, and `version_id` is used instead.
73
+ """
74
+ return None
75
+
76
+ def is_installed(
77
+ self,
78
+ game: GameDir,
79
+ *,
80
+ session: Session | None = None,
81
+ cache_dir: Path | None = None,
82
+ ) -> bool:
83
+ """Return whether this loader is already installed in ``game``.
84
+
85
+ The built-in loaders answer this from local files, with no network use.
86
+
87
+ Args:
88
+ game: The game directory.
89
+ session: The session to use, if the loader has to ask a service.
90
+ cache_dir: The cache directory. Defaults to the per-user cache.
91
+
92
+ Returns:
93
+ True if its version JSON exists.
94
+ """
95
+ expected = self.expected_id()
96
+ if expected is not None:
97
+ return game.has_version(expected)
98
+ return game.has_version(self.version_id(session=session, cache_dir=cache_dir))
99
+
100
+
101
+ _registry: dict[str, type[Loader]] = {}
102
+ _aliases = {"fabric-loader": "fabric", "quilt-loader": "quilt"}
103
+
104
+
105
+ def register(cls: type[Loader]) -> type[Loader]:
106
+ """Make a `Loader` subclass available to `create` and `parse`.
107
+
108
+ It can be used as a class decorator.
109
+
110
+ Args:
111
+ cls: The subclass.
112
+
113
+ Returns:
114
+ The same subclass.
115
+ """
116
+ _registry[cls.kind] = cls
117
+ return cls
118
+
119
+
120
+ def create(kind: str, version: str, minecraft: str) -> Loader:
121
+ """Build a loader from a name such as ``neoforge`` or ``fabric-loader``.
122
+
123
+ Args:
124
+ kind: The loader's name, in any case.
125
+ version: The loader version. A leading ``<minecraft>-`` is removed.
126
+ minecraft: The Minecraft version.
127
+
128
+ Returns:
129
+ The loader.
130
+
131
+ Raises:
132
+ UnsupportedError: No loader is registered under that name.
133
+ """
134
+ name = kind.lower()
135
+ cls = _registry.get(_aliases.get(name, name))
136
+ if cls is None:
137
+ raise UnsupportedError(f"Unsupported mod loader: {kind}")
138
+ return cls(version.removeprefix(f"{minecraft}-"), minecraft)
139
+
140
+
141
+ def parse(loader_id: str, minecraft: str) -> Loader:
142
+ """Build a loader from an id such as ``forge-47.3.0``.
143
+
144
+ Args:
145
+ loader_id: The loader's name and version, joined by a hyphen.
146
+ minecraft: The Minecraft version.
147
+
148
+ Returns:
149
+ The loader.
150
+
151
+ Raises:
152
+ UnsupportedError: The id is malformed or the loader is unknown.
153
+ """
154
+ kind, _, version = loader_id.partition("-")
155
+ if not version:
156
+ raise UnsupportedError(f"Unsupported mod loader: {loader_id}")
157
+ return create(kind, version, minecraft)
@@ -0,0 +1,166 @@
1
+ """Loaders installed from a metadata service: Fabric and Quilt."""
2
+
3
+ from abc import abstractmethod
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any, ClassVar
7
+
8
+ from ..minecraft import GameDir, install_version, maven_path
9
+ from ..net import Resource, Session, download
10
+ from ..net._session import session_scope
11
+ from ..progress import CancelToken, ProgressCallback
12
+ from ._base import Loader, register
13
+
14
+ HASH_FIELDS = ("sha512", "sha256", "sha1", "md5")
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class MetaLoader(Loader):
19
+ """A loader described by a version JSON from a metadata service.
20
+
21
+ Attributes:
22
+ meta_url: The service's base URL.
23
+ default_maven: The repository for libraries that do not name one.
24
+ """
25
+
26
+ meta_url: str = ""
27
+ default_maven: ClassVar[str]
28
+
29
+ @abstractmethod
30
+ def profile_url(self) -> str:
31
+ """Return where this loader's version JSON is served."""
32
+
33
+ def expected_id(self) -> str:
34
+ """Return the version id that the metadata service gives this loader."""
35
+ return f"{self.kind}-loader-{self.version}-{self.minecraft}"
36
+
37
+ def profile(self, session: Session | None = None) -> dict[str, Any]:
38
+ """Fetch this loader's version JSON.
39
+
40
+ Args:
41
+ session: The session to use. A temporary one is created if omitted.
42
+
43
+ Returns:
44
+ The decoded JSON.
45
+
46
+ Raises:
47
+ NetworkError: The service failed or does not know this version.
48
+ """
49
+ with session_scope(session) as client:
50
+ return client.get_json(self.profile_url())
51
+
52
+ def version_id(
53
+ self, *, session: Session | None = None, cache_dir: Path | None = None
54
+ ) -> str:
55
+ """Return the id from the metadata service.
56
+
57
+ Args:
58
+ session: The session to use. A temporary one is created if omitted.
59
+ cache_dir: Not used by this loader.
60
+
61
+ Returns:
62
+ The version id.
63
+
64
+ Raises:
65
+ NetworkError: The service failed or does not know this version.
66
+ """
67
+ return self.profile(session)["id"]
68
+
69
+ def _resource(self, library: dict[str, Any], game: GameDir) -> Resource:
70
+ """Describe where to download one of the profile's libraries."""
71
+ path = maven_path(library["name"])
72
+ base: str = library.get("url") or self.default_maven
73
+ hashes = {name: library[name] for name in HASH_FIELDS if library.get(name)}
74
+ return Resource.of(
75
+ f"{base.rstrip('/')}/{path}",
76
+ game.libraries_dir / path,
77
+ hashes,
78
+ library.get("size"),
79
+ )
80
+
81
+ def install(
82
+ self,
83
+ game: GameDir,
84
+ *,
85
+ java: Path | None = None,
86
+ cache_dir: Path | None = None,
87
+ session: Session | None = None,
88
+ progress: ProgressCallback | None = None,
89
+ cancel: CancelToken | None = None,
90
+ ) -> str:
91
+ """Install vanilla Minecraft, the libraries and the version JSON.
92
+
93
+ This does nothing if the version is already installed.
94
+
95
+ Args:
96
+ game: The game directory.
97
+ java: The Java binary to use. Not used by this loader.
98
+ cache_dir: The cache directory. Not used by this loader.
99
+ session: The session to use. A temporary one is created if omitted.
100
+ progress: Receives updates for each stage.
101
+ cancel: Stops the install.
102
+
103
+ Returns:
104
+ The installed version id.
105
+
106
+ Raises:
107
+ NetworkError: The metadata service could not be reached.
108
+ UnsupportedError: Mojang does not publish this Minecraft version.
109
+ DownloadError: A file could not be downloaded.
110
+ Cancelled: ``cancel`` was triggered.
111
+ """
112
+ if game.has_version(self.expected_id()):
113
+ return self.expected_id()
114
+ with session_scope(session) as client:
115
+ profile = self.profile(client)
116
+ version_id: str = profile["id"]
117
+ if game.has_version(version_id):
118
+ return version_id
119
+ install_version(
120
+ game, self.minecraft, session=client, progress=progress, cancel=cancel
121
+ )
122
+ download(
123
+ [self._resource(item, game) for item in profile["libraries"]],
124
+ session=client,
125
+ progress=progress,
126
+ cancel=cancel,
127
+ task="libraries",
128
+ )
129
+ game.write_version(version_id, profile)
130
+ return version_id
131
+
132
+
133
+ @register
134
+ @dataclass(frozen=True)
135
+ class Fabric(MetaLoader):
136
+ """The Fabric loader.
137
+
138
+ Attributes:
139
+ meta_url: The base URL of Fabric's metadata service.
140
+ """
141
+
142
+ kind: ClassVar[str] = "fabric"
143
+ default_maven: ClassVar[str] = "https://maven.fabricmc.net/"
144
+ meta_url: str = "https://meta.fabricmc.net/v2"
145
+
146
+ def profile_url(self) -> str:
147
+ """Return where this version's JSON is served."""
148
+ return f"{self.meta_url.rstrip('/')}/versions/loader/{self.minecraft}/{self.version}/profile/json"
149
+
150
+
151
+ @register
152
+ @dataclass(frozen=True)
153
+ class Quilt(MetaLoader):
154
+ """The Quilt loader.
155
+
156
+ Attributes:
157
+ meta_url: The base URL of Quilt's metadata service.
158
+ """
159
+
160
+ kind: ClassVar[str] = "quilt"
161
+ default_maven: ClassVar[str] = "https://maven.quiltmc.org/repository/release/"
162
+ meta_url: str = "https://meta.quiltmc.org/v3"
163
+
164
+ def profile_url(self) -> str:
165
+ """Return where this version's JSON is served."""
166
+ return f"{self.meta_url.rstrip('/')}/versions/loader/{self.minecraft}/{self.version}/profile/json"
@@ -0,0 +1,312 @@
1
+ """Loaders installed by running processors: Forge and NeoForge."""
2
+
3
+ import logging
4
+ import tempfile
5
+ from abc import abstractmethod
6
+ from collections.abc import Iterator
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import ClassVar
10
+
11
+ from .. import java as java_runtime
12
+ from .._support import default_cache_dir
13
+ from ..errors import JavaError, LoaderError, NetworkError, ProcessorError
14
+ from ..minecraft import GameDir, install_version
15
+ from ..net import Resource, Session, download
16
+ from ..net._session import session_scope
17
+ from ..progress import CancelToken, ProgressCallback
18
+ from ._base import Loader, register
19
+ from ._install_profile import InstallerJar, Library, Variables
20
+ from ._processors import run_processors
21
+
22
+ logger = logging.getLogger(__name__)
23
+ LEGACY_NEOFORGE = "1.20.1"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ProcessorLoader(Loader):
28
+ """A loader installed by patching Minecraft with processor jars.
29
+
30
+ Attributes:
31
+ repository: The Maven repository that hosts the installers.
32
+ java_index_url: Where to read Mojang's runtime index.
33
+ """
34
+
35
+ repository: str = ""
36
+ java_index_url: str = java_runtime.RUNTIME_INDEX_URL
37
+
38
+ @abstractmethod
39
+ def installer_url(self) -> str:
40
+ """Return where this version's installer jar is published."""
41
+
42
+ def fetch_installer(
43
+ self, *, session: Session | None = None, cache_dir: Path | None = None
44
+ ) -> InstallerJar:
45
+ """Return this version's installer, downloading it if it is not cached.
46
+
47
+ The download is checked against the published SHA-1 when there is one.
48
+
49
+ Args:
50
+ session: The session to use. A temporary one is created if omitted.
51
+ cache_dir: The cache directory. Defaults to the per-user cache.
52
+
53
+ Returns:
54
+ The installer.
55
+
56
+ Raises:
57
+ DownloadError: The installer could not be downloaded.
58
+ LoaderError: The installer is malformed.
59
+ UnsupportedError: The installer is older than Minecraft 1.13.
60
+ """
61
+ url = self.installer_url()
62
+ dest = (cache_dir or default_cache_dir()) / "installers" / url.rsplit("/", 1)[1]
63
+ if not dest.is_file():
64
+ with session_scope(session) as client:
65
+ try:
66
+ body = client.request("GET", f"{url}.sha1").text
67
+ hashes = {"sha1": body.split()[0]}
68
+ except (NetworkError, IndexError):
69
+ hashes = None
70
+ download(Resource.of(url, dest, hashes), session=client)
71
+ return InstallerJar(dest)
72
+
73
+ def version_id(
74
+ self, *, session: Session | None = None, cache_dir: Path | None = None
75
+ ) -> str:
76
+ """Return the id declared by the installer, downloading it if needed.
77
+
78
+ Args:
79
+ session: The session to use. A temporary one is created if omitted.
80
+ cache_dir: The cache directory. Defaults to the per-user cache.
81
+
82
+ Returns:
83
+ The version id.
84
+
85
+ Raises:
86
+ DownloadError: The installer could not be downloaded.
87
+ LoaderError: The installer is malformed.
88
+ UnsupportedError: The installer is older than Minecraft 1.13.
89
+ """
90
+ return self.fetch_installer(session=session, cache_dir=cache_dir).version_id
91
+
92
+ def _resource(self, library: Library, game: GameDir) -> Resource | None:
93
+ """Describe a library's download, or None if the installer provided it."""
94
+ dest = game.libraries_dir / library.path
95
+ if library.url is None and dest.is_file():
96
+ return None
97
+ url = library.url or f"{self.repository.rstrip('/')}/{library.path}"
98
+ return Resource.of(url, dest, library.hashes, library.size)
99
+
100
+ def install(
101
+ self,
102
+ game: GameDir,
103
+ *,
104
+ java: Path | None = None,
105
+ cache_dir: Path | None = None,
106
+ session: Session | None = None,
107
+ progress: ProgressCallback | None = None,
108
+ cancel: CancelToken | None = None,
109
+ ) -> str:
110
+ """Install vanilla Minecraft, the libraries and the patched game files.
111
+
112
+ The version is registered last, so a failed install leaves no broken version
113
+ behind. This does nothing if the version is already installed.
114
+
115
+ Args:
116
+ game: The game directory.
117
+ java: The Java binary to use. By default, installed runtimes are tried
118
+ first, then runtimes downloaded from Mojang. ``jre-legacy`` is
119
+ the last choice.
120
+ cache_dir: The cache directory. Defaults to the per-user cache.
121
+ session: The session to use. A temporary one is created if omitted.
122
+ progress: Receives updates for each stage.
123
+ cancel: Stops the install.
124
+
125
+ Returns:
126
+ The installed version id.
127
+
128
+ Raises:
129
+ LoaderError: The installer is malformed or targets another Minecraft
130
+ version.
131
+ ProcessorError: A processor failed.
132
+ JavaError: No usable Java runtime could be found or downloaded.
133
+ UnsupportedError: The version is not supported.
134
+ NetworkError: Mojang's version manifest could not be fetched.
135
+ DownloadError: A file could not be downloaded.
136
+ Cancelled: ``cancel`` was triggered.
137
+ """
138
+ expected = self.expected_id()
139
+ if expected is not None and game.has_version(expected):
140
+ return expected
141
+ if cache_dir is not None:
142
+ cache_dir = Path(cache_dir).resolve() # processors run in another directory
143
+ with session_scope(session) as client:
144
+ installer = self.fetch_installer(session=client, cache_dir=cache_dir)
145
+ if game.has_version(installer.version_id):
146
+ return installer.version_id
147
+ if installer.profile.minecraft != self.minecraft:
148
+ raise LoaderError(
149
+ f"Installer targets Minecraft {installer.profile.minecraft}"
150
+ )
151
+
152
+ install_version(
153
+ game, self.minecraft, session=client, progress=progress, cancel=cancel
154
+ )
155
+ installer.extract_bundled_libraries(game.libraries_dir)
156
+ libraries = [
157
+ *installer.profile.libraries,
158
+ # Entries with no URL are processor outputs, which cannot be downloaded.
159
+ *(item for item in installer.version_libraries if item.url),
160
+ ]
161
+ resources = [self._resource(item, game) for item in libraries]
162
+ download(
163
+ [item for item in resources if item],
164
+ session=client,
165
+ progress=progress,
166
+ cancel=cancel,
167
+ task="libraries",
168
+ )
169
+ self._patch(installer, game, java, cache_dir, client, progress, cancel)
170
+ game.write_version(installer.version_id, installer.version_json)
171
+ return installer.version_id
172
+
173
+ def _patch(
174
+ self,
175
+ installer: InstallerJar,
176
+ game: GameDir,
177
+ java: Path | None,
178
+ cache_dir: Path | None,
179
+ session: Session,
180
+ progress: ProgressCallback | None,
181
+ cancel: CancelToken | None,
182
+ ) -> None:
183
+ """Run the client processors, trying Java runtimes until one works."""
184
+ processors = installer.profile.client_processors()
185
+ if not processors:
186
+ return
187
+ with tempfile.TemporaryDirectory(prefix="modsmith-installer-") as temp:
188
+ variables = Variables.build(
189
+ installer,
190
+ game.libraries_dir,
191
+ game.path,
192
+ game.client_jar(self.minecraft),
193
+ Path(temp),
194
+ )
195
+
196
+ def attempt(executable: Path) -> None:
197
+ run_processors(
198
+ processors,
199
+ variables,
200
+ executable,
201
+ game.libraries_dir,
202
+ progress,
203
+ cancel,
204
+ )
205
+
206
+ if java is not None:
207
+ attempt(java)
208
+ return
209
+
210
+ failure: ProcessorError | None = None
211
+ runtimes = self._runtimes(game, cache_dir, session, progress, cancel)
212
+ for runtime in runtimes:
213
+ try:
214
+ attempt(runtime.executable)
215
+ return
216
+ except ProcessorError as e:
217
+ failure = e
218
+ logger.warning("Processors failed on %s: %s", runtime.component, e)
219
+ raise failure or JavaError(f"No usable Java runtime for {self.minecraft}")
220
+
221
+ def _runtimes(
222
+ self,
223
+ game: GameDir,
224
+ cache_dir: Path | None,
225
+ session: Session,
226
+ progress: ProgressCallback | None,
227
+ cancel: CancelToken | None,
228
+ ) -> Iterator[java_runtime.JavaRuntime]:
229
+ """Yield Java runtimes to try, installed ones first and downloads after.
230
+
231
+ Processors are install tools, so they do not need the Java that the game
232
+ runs on. ``jre-legacy`` is the last choice, because Mojang's Windows build
233
+ breaks some installs and there is no build for Apple Silicon.
234
+ """
235
+ required = java_runtime.required_for(game.version(self.minecraft), game)
236
+ legacy = required == java_runtime.LEGACY_COMPONENT
237
+ exact = java_runtime.find(required, game=game, cache_dir=cache_dir)
238
+ installed = java_runtime.find_all(game=game, cache_dir=cache_dir)
239
+ tried: set[str] = set()
240
+ for runtime in [*([] if legacy or exact is None else [exact]), *installed]:
241
+ if runtime.component not in tried:
242
+ tried.add(runtime.component)
243
+ yield runtime
244
+
245
+ fallback = java_runtime.FALLBACK_COMPONENT
246
+ for component in [fallback, required] if legacy else [required, fallback]:
247
+ if component in tried:
248
+ continue
249
+ tried.add(component)
250
+ try:
251
+ runtime = java_runtime.download(
252
+ component,
253
+ cache_dir=cache_dir,
254
+ session=session,
255
+ progress=progress,
256
+ cancel=cancel,
257
+ index_url=self.java_index_url,
258
+ )
259
+ except JavaError as e:
260
+ logger.warning("Cannot use %s: %s", component, e)
261
+ continue
262
+ yield runtime
263
+
264
+
265
+ @register
266
+ @dataclass(frozen=True)
267
+ class Forge(ProcessorLoader):
268
+ """The Forge loader, for Minecraft 1.13 and later.
269
+
270
+ Attributes:
271
+ repository: The Maven repository that hosts the installers.
272
+ """
273
+
274
+ kind: ClassVar[str] = "forge"
275
+ repository: str = "https://maven.minecraftforge.net"
276
+
277
+ def expected_id(self) -> str:
278
+ """Return the version id that this version's installer declares."""
279
+ return f"{self.minecraft}-forge-{self.version}"
280
+
281
+ def installer_url(self) -> str:
282
+ """Return where this version's installer jar is published."""
283
+ version = f"{self.minecraft}-{self.version}"
284
+ return f"{self.repository.rstrip('/')}/net/minecraftforge/forge/{version}/forge-{version}-installer.jar"
285
+
286
+
287
+ @register
288
+ @dataclass(frozen=True)
289
+ class NeoForge(ProcessorLoader):
290
+ """The NeoForge loader.
291
+
292
+ Attributes:
293
+ repository: The Maven repository that hosts the installers.
294
+ """
295
+
296
+ kind: ClassVar[str] = "neoforge"
297
+ repository: str = "https://maven.neoforged.net/releases"
298
+
299
+ def expected_id(self) -> str:
300
+ """Return the version id that this version's installer declares."""
301
+ if self.minecraft == LEGACY_NEOFORGE:
302
+ return f"{self.minecraft}-forge-{self.version}"
303
+ return f"neoforge-{self.version}"
304
+
305
+ def installer_url(self) -> str:
306
+ """Return where this version's installer jar is published."""
307
+ base = self.repository.rstrip("/")
308
+ # NeoForge's first release still used Forge's artifact name.
309
+ if self.minecraft == LEGACY_NEOFORGE:
310
+ version = f"{self.minecraft}-{self.version}"
311
+ return f"{base}/net/neoforged/forge/{version}/forge-{version}-installer.jar"
312
+ return f"{base}/net/neoforged/neoforge/{self.version}/neoforge-{self.version}-installer.jar"