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,168 @@
1
+ """The layout of a game directory and its version files."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .._support import write_json
11
+ from ..errors import UnsupportedError
12
+ from ._maven import maven_path
13
+
14
+
15
+ def default_game_path() -> Path:
16
+ """Return the official launcher's default game directory for this system.
17
+
18
+ On Linux, the Flatpak directory is used if only that one exists.
19
+ """
20
+ home = Path.home()
21
+ if sys.platform == "win32":
22
+ appdata = os.getenv("APPDATA")
23
+ return Path(appdata, ".minecraft") if appdata else home / ".minecraft"
24
+ if sys.platform == "darwin":
25
+ return home / "Library" / "Application Support" / "minecraft"
26
+ default = home / ".minecraft"
27
+ flatpak = home / ".var" / "app" / "com.mojang.Minecraft" / ".minecraft"
28
+ return flatpak if not default.exists() and flatpak.exists() else default
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Version:
33
+ """A parsed version JSON, vanilla or from a mod loader.
34
+
35
+ Attributes:
36
+ id: The version id.
37
+ inherits_from: The parent version of a loader version, or None.
38
+ java_component: The Mojang runtime it asks for, or None.
39
+ java_major: The major Java version it asks for, or None.
40
+ client_url: Where to download the client jar, or None.
41
+ client_sha1: The client jar's SHA-1, or None.
42
+ client_size: The client jar's size in bytes, or None.
43
+ raw: The original JSON data.
44
+ """
45
+
46
+ id: str
47
+ inherits_from: str | None = None
48
+ java_component: str | None = None
49
+ java_major: int | None = None
50
+ client_url: str | None = None
51
+ client_sha1: str | None = None
52
+ client_size: int | None = None
53
+ raw: dict[str, Any] = field(
54
+ default_factory=dict[str, Any], repr=False, compare=False
55
+ )
56
+
57
+ @classmethod
58
+ def from_dict(cls, data: dict[str, Any]) -> "Version":
59
+ """Build a version from decoded JSON.
60
+
61
+ Args:
62
+ data: The version JSON.
63
+
64
+ Returns:
65
+ The parsed version.
66
+
67
+ Raises:
68
+ KeyError: The data has no ``id``.
69
+ """
70
+ java: dict[str, Any] = data.get("javaVersion") or {}
71
+ downloads: dict[str, Any] = data.get("downloads") or {}
72
+ client: dict[str, Any] = downloads.get("client") or {}
73
+ return cls(
74
+ id=data["id"],
75
+ inherits_from=data.get("inheritsFrom"),
76
+ java_component=java.get("component"),
77
+ java_major=java.get("majorVersion"),
78
+ client_url=client.get("url"),
79
+ client_sha1=client.get("sha1"),
80
+ client_size=client.get("size"),
81
+ raw=data,
82
+ )
83
+
84
+ @classmethod
85
+ def load(cls, path: Path) -> "Version":
86
+ """Read a version from a JSON file."""
87
+ return cls.from_dict(json.loads(path.read_text(encoding="utf-8")))
88
+
89
+
90
+ class GameDir:
91
+ """A ``.minecraft`` directory and the layout of the files inside it.
92
+
93
+ Args:
94
+ path: The directory. It does not have to exist. A relative path is made
95
+ absolute, so it stays valid if the working directory changes.
96
+
97
+ Attributes:
98
+ path: The absolute path of the directory.
99
+ """
100
+
101
+ def __init__(self, path: Path | str) -> None:
102
+ self.path = Path(path).resolve()
103
+
104
+ @classmethod
105
+ def default(cls) -> "GameDir":
106
+ """Return the official launcher's default directory for this system."""
107
+ return cls(default_game_path())
108
+
109
+ def __repr__(self) -> str:
110
+ return f"GameDir({str(self.path)!r})"
111
+
112
+ @property
113
+ def versions_dir(self) -> Path:
114
+ """The directory that holds installed versions."""
115
+ return self.path / "versions"
116
+
117
+ @property
118
+ def libraries_dir(self) -> Path:
119
+ """The directory that holds libraries."""
120
+ return self.path / "libraries"
121
+
122
+ def version_json(self, version_id: str) -> Path:
123
+ """Return the path of a version's JSON file."""
124
+ return self.versions_dir / version_id / f"{version_id}.json"
125
+
126
+ def client_jar(self, version_id: str) -> Path:
127
+ """Return the path of a version's client jar."""
128
+ return self.versions_dir / version_id / f"{version_id}.jar"
129
+
130
+ def library(self, coord: str) -> Path:
131
+ """Return the path of the library with a Maven coordinate."""
132
+ return self.libraries_dir / maven_path(coord)
133
+
134
+ def has_version(self, version_id: str) -> bool:
135
+ """Return whether a version's JSON file exists."""
136
+ return self.version_json(version_id).is_file()
137
+
138
+ def installed_versions(self) -> list[str]:
139
+ """List the installed version ids, sorted by name."""
140
+ if not self.versions_dir.is_dir():
141
+ return []
142
+ names = sorted(path.name for path in self.versions_dir.iterdir())
143
+ return [name for name in names if self.has_version(name)]
144
+
145
+ def version(self, version_id: str) -> Version:
146
+ """Read an installed version.
147
+
148
+ Args:
149
+ version_id: The version id.
150
+
151
+ Returns:
152
+ The parsed version.
153
+
154
+ Raises:
155
+ UnsupportedError: The version is not installed.
156
+ """
157
+ if not self.has_version(version_id):
158
+ raise UnsupportedError(f"Version {version_id} is not installed")
159
+ return Version.load(self.version_json(version_id))
160
+
161
+ def write_version(self, version_id: str, data: dict[str, Any]) -> None:
162
+ """Write a version's JSON file.
163
+
164
+ Args:
165
+ version_id: The version id.
166
+ data: The version JSON.
167
+ """
168
+ write_json(self.version_json(version_id), data)
@@ -0,0 +1,75 @@
1
+ """Maven coordinates, used to locate library files."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class MavenCoord:
8
+ """A parsed coordinate such as ``group:artifact:version:classifier@extension``.
9
+
10
+ Attributes:
11
+ group: The group id.
12
+ artifact: The artifact id.
13
+ version: The version.
14
+ classifier: The classifier, or None.
15
+ extension: The file extension.
16
+ """
17
+
18
+ group: str
19
+ artifact: str
20
+ version: str
21
+ classifier: str | None = None
22
+ extension: str = "jar"
23
+
24
+ @classmethod
25
+ def parse(cls, coord: str) -> "MavenCoord":
26
+ """Parse a coordinate.
27
+
28
+ Args:
29
+ coord: The coordinate. The classifier and extension are optional.
30
+
31
+ Returns:
32
+ The parsed coordinate.
33
+
34
+ Raises:
35
+ ValueError: The coordinate is malformed.
36
+ """
37
+ body, _, extension = coord.partition("@")
38
+ parts = body.split(":")
39
+ if not 3 <= len(parts) <= 4 or "@" in extension:
40
+ raise ValueError(f"Invalid maven coordinate: {coord}")
41
+ return cls(
42
+ group=parts[0],
43
+ artifact=parts[1],
44
+ version=parts[2],
45
+ classifier=parts[3] if len(parts) == 4 else None,
46
+ extension=extension or "jar",
47
+ )
48
+
49
+ @property
50
+ def path(self) -> str:
51
+ """The file's path inside a Maven repository."""
52
+ name = f"{self.artifact}-{self.version}"
53
+ if self.classifier:
54
+ name += f"-{self.classifier}"
55
+ group = self.group.replace(".", "/")
56
+ return f"{group}/{self.artifact}/{self.version}/{name}.{self.extension}"
57
+
58
+
59
+ def maven_path(coord: str) -> str:
60
+ """Convert a coordinate to its path inside a Maven repository.
61
+
62
+ Args:
63
+ coord: The coordinate.
64
+
65
+ Returns:
66
+ The relative path, with forward slashes.
67
+
68
+ Raises:
69
+ ValueError: The coordinate is malformed.
70
+
71
+ Example:
72
+ >>> maven_path("net.fabricmc:fabric-loader:0.16.10")
73
+ 'net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar'
74
+ """
75
+ return MavenCoord.parse(coord).path
@@ -0,0 +1,115 @@
1
+ """Mojang's version manifest and vanilla game downloads."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from ..errors import UnsupportedError
6
+ from ..net import Resource, Session, download
7
+ from ..net._session import session_scope
8
+ from ..progress import CancelToken, ProgressCallback
9
+ from ._game_dir import GameDir, Version
10
+
11
+ VERSION_MANIFEST_URL = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class VersionInfo:
16
+ """One entry in Mojang's version manifest.
17
+
18
+ Attributes:
19
+ id: The version id.
20
+ type: The kind of version, such as ``release`` or ``snapshot``.
21
+ url: Where to download the version JSON.
22
+ sha1: The version JSON's SHA-1.
23
+ release_time: When the version was released, in ISO 8601 format.
24
+ """
25
+
26
+ id: str
27
+ type: str
28
+ url: str
29
+ sha1: str
30
+ release_time: str
31
+
32
+
33
+ def list_versions(
34
+ session: Session | None = None, *, manifest_url: str = VERSION_MANIFEST_URL
35
+ ) -> list[VersionInfo]:
36
+ """List every Minecraft version Mojang publishes, newest first.
37
+
38
+ Args:
39
+ session: The session to use. A temporary one is created if omitted.
40
+ manifest_url: Where to read the manifest.
41
+
42
+ Returns:
43
+ The manifest's entries.
44
+
45
+ Raises:
46
+ NetworkError: The manifest could not be fetched.
47
+ """
48
+ with session_scope(session) as client:
49
+ manifest = client.get_json(manifest_url)
50
+ return [
51
+ VersionInfo(v["id"], v["type"], v["url"], v["sha1"], v["releaseTime"])
52
+ for v in manifest["versions"]
53
+ ]
54
+
55
+
56
+ def install_version(
57
+ game: GameDir,
58
+ version_id: str,
59
+ *,
60
+ session: Session | None = None,
61
+ progress: ProgressCallback | None = None,
62
+ cancel: CancelToken | None = None,
63
+ manifest_url: str = VERSION_MANIFEST_URL,
64
+ ) -> Version:
65
+ """Download a vanilla version's JSON and client jar into ``game``.
66
+
67
+ Files that are already valid are kept.
68
+
69
+ Args:
70
+ game: The game directory.
71
+ version_id: The Minecraft version.
72
+ session: The session to use. A temporary one is created if omitted.
73
+ progress: Receives updates while the client jar downloads.
74
+ cancel: Stops the download.
75
+ manifest_url: Where to read Mojang's version manifest.
76
+
77
+ Returns:
78
+ The installed version.
79
+
80
+ Raises:
81
+ UnsupportedError: The version is unknown or has no client download.
82
+ NetworkError: The manifest could not be fetched.
83
+ DownloadError: A file could not be downloaded.
84
+ Cancelled: ``cancel`` was triggered.
85
+ """
86
+ with session_scope(session) as client:
87
+ if not game.has_version(version_id):
88
+ info = next(
89
+ (
90
+ v
91
+ for v in list_versions(client, manifest_url=manifest_url)
92
+ if v.id == version_id
93
+ ),
94
+ None,
95
+ )
96
+ if info is None:
97
+ raise UnsupportedError(f"Unknown Minecraft version: {version_id}")
98
+ meta = Resource.of(
99
+ info.url, game.version_json(version_id), {"sha1": info.sha1}
100
+ )
101
+ download(meta, session=client, cancel=cancel)
102
+
103
+ version = game.version(version_id)
104
+ if version.client_url is None:
105
+ raise UnsupportedError(f"Version {version_id} has no client download")
106
+ jar = Resource.of(
107
+ version.client_url,
108
+ game.client_jar(version_id),
109
+ {"sha1": version.client_sha1} if version.client_sha1 else None,
110
+ version.client_size,
111
+ )
112
+ download(
113
+ jar, session=client, progress=progress, cancel=cancel, task="minecraft"
114
+ )
115
+ return version
@@ -0,0 +1,7 @@
1
+ """HTTP access and verified, resumable downloads."""
2
+
3
+ from ._download import download
4
+ from ._resource import Resource, file_hash
5
+ from ._session import Session
6
+
7
+ __all__ = ["Resource", "Session", "download", "file_hash"]
@@ -0,0 +1,243 @@
1
+ """Parallel downloads with verification and resume."""
2
+
3
+ import logging
4
+ import re
5
+ import threading
6
+ from collections.abc import Callable, Iterable
7
+ from concurrent.futures import Future, ThreadPoolExecutor, as_completed
8
+ from pathlib import Path
9
+
10
+ import requests
11
+
12
+ from .._support import replace_file
13
+ from ..errors import Cancelled, DownloadError, IntegrityError, NetworkError
14
+ from ..progress import CancelToken, ProgressCallback, report
15
+ from ._resource import Resource, matches, strongest_hash
16
+ from ._session import Session, is_retryable
17
+
18
+ logger = logging.getLogger(__name__)
19
+ CHUNK_SIZE = 65536
20
+ OnBytes = Callable[[int], None]
21
+
22
+
23
+ class _Meter:
24
+ """Tracks how many bytes of one file count toward the overall progress."""
25
+
26
+ def __init__(self, on_bytes: OnBytes) -> None:
27
+ self.on_bytes = on_bytes
28
+ self.counted = 0
29
+
30
+ def add(self, count: int) -> None:
31
+ """Count more bytes. A negative count takes bytes back."""
32
+ if count:
33
+ self.counted += count
34
+ self.on_bytes(count)
35
+
36
+ def reset(self, count: int) -> None:
37
+ """Set the count, for a file that was skipped, resumed or restarted."""
38
+ self.add(count - self.counted)
39
+
40
+
41
+ def _verify(resource: Resource, part: Path) -> None:
42
+ """Check a finished ``.part`` file, raising `IntegrityError` if it is wrong."""
43
+ if resource.size is not None and part.stat().st_size != resource.size:
44
+ raise IntegrityError(f"Size mismatch for {resource.path.name}")
45
+ if not matches(part, resource.hashes):
46
+ raise IntegrityError(f"Hash mismatch for {resource.path.name}")
47
+
48
+
49
+ def _range_start(response: requests.Response) -> int | None:
50
+ """Return where a partial response starts, if it says."""
51
+ match = re.match(r"bytes (\d+)-", response.headers.get("Content-Range", ""))
52
+ return int(match[1]) if match else None
53
+
54
+
55
+ def _validator(response: requests.Response) -> str | None:
56
+ """Return a value that identifies this version of a file for ``If-Range``."""
57
+ etag = response.headers.get("ETag")
58
+ # ``If-Range`` only works with strong validators.
59
+ if etag and not etag.startswith("W/"):
60
+ return etag
61
+ return response.headers.get("Last-Modified")
62
+
63
+
64
+ def _stream(
65
+ url: str,
66
+ resource: Resource,
67
+ part: Path,
68
+ session: Session,
69
+ meter: _Meter,
70
+ cancel: CancelToken | None,
71
+ validators: dict[str, str],
72
+ ) -> None:
73
+ """Fetch one URL into ``part``, continuing from what is already there.
74
+
75
+ ``validators`` remembers which version of the file each URL served, so a
76
+ resume cannot join two versions.
77
+ """
78
+ offset = part.stat().st_size if part.exists() else 0
79
+ if resource.size is not None and offset >= resource.size:
80
+ part.touch() # an empty file has nothing to stream
81
+ meter.reset(offset)
82
+ return
83
+ headers: dict[str, str] = {}
84
+ if offset:
85
+ headers["Range"] = f"bytes={offset}-"
86
+ if url in validators:
87
+ headers["If-Range"] = validators[url]
88
+ with session.stream(url, headers, accept=(416,)) as response:
89
+ if response.status_code == 416:
90
+ raise IntegrityError(f"Stale partial file for {url}")
91
+ # A 200 means the server sent the whole file, either because it ignored
92
+ # the range or because the file changed.
93
+ resuming = offset > 0 and response.status_code == 206
94
+ if resuming and _range_start(response) not in (None, offset):
95
+ raise IntegrityError(f"Wrong range from {url}")
96
+ validator = _validator(response)
97
+ if validator:
98
+ validators[url] = validator
99
+ meter.reset(offset if resuming else 0)
100
+ with open(part, "ab" if resuming else "wb") as file:
101
+ for chunk in response.iter_content(CHUNK_SIZE):
102
+ if cancel is not None:
103
+ cancel.check()
104
+ file.write(chunk)
105
+ meter.add(len(chunk))
106
+
107
+
108
+ def _fetch(
109
+ resource: Resource,
110
+ session: Session,
111
+ on_bytes: OnBytes,
112
+ cancel: CancelToken | None,
113
+ ) -> None:
114
+ """Download one resource, trying each URL in turn.
115
+
116
+ `Session.stream` has already retried a failure to connect, so that moves on
117
+ to the next URL. Only a broken transfer or a bad file is retried here.
118
+
119
+ Raises:
120
+ DownloadError: Every URL failed.
121
+ Cancelled: ``cancel`` was triggered.
122
+ """
123
+ meter = _Meter(on_bytes)
124
+ if resource.is_present():
125
+ meter.reset(resource.size or 0)
126
+ return
127
+ if not resource.urls:
128
+ raise DownloadError(f"{resource.path.name} has no download URL")
129
+ resource.path.parent.mkdir(parents=True, exist_ok=True)
130
+ part = resource.path.with_name(resource.path.name + ".part")
131
+ # Without a hash, nothing could prove a leftover file is the same version.
132
+ if strongest_hash(resource.hashes) is None:
133
+ part.unlink(missing_ok=True)
134
+
135
+ validators: dict[str, str] = {}
136
+ last: Exception | None = None
137
+ for url in resource.urls:
138
+ for attempt in range(session.attempts):
139
+ if attempt:
140
+ session.wait(attempt - 1)
141
+ if cancel is not None:
142
+ cancel.check()
143
+ try:
144
+ _stream(url, resource, part, session, meter, cancel, validators)
145
+ _verify(resource, part)
146
+ except IntegrityError as e:
147
+ part.unlink(missing_ok=True)
148
+ last = e
149
+ continue
150
+ except NetworkError as e:
151
+ last = e
152
+ break
153
+ except requests.RequestException as e:
154
+ last = e
155
+ if not is_retryable(e):
156
+ break
157
+ continue
158
+ replace_file(part, resource.path)
159
+ return
160
+ logger.warning("Download from %s failed: %s", url, last)
161
+ raise DownloadError(f"Failed to download {resource.path.name}: {last}") from last
162
+
163
+
164
+ def download(
165
+ resources: Resource | Iterable[Resource],
166
+ *,
167
+ session: Session | None = None,
168
+ max_workers: int = 8,
169
+ progress: ProgressCallback | None = None,
170
+ cancel: CancelToken | None = None,
171
+ task: str = "download",
172
+ ) -> None:
173
+ """Download resources in parallel, skipping files that are already valid.
174
+
175
+ Interrupted files resume from their ``.part`` file. All files are attempted
176
+ even if some fail. Resources with the same path are downloaded once.
177
+
178
+ Args:
179
+ resources: One resource, or several.
180
+ session: The session to use. A temporary one is created if omitted.
181
+ max_workers: How many files to download at once.
182
+ progress: Called with byte counts if every size is known, otherwise with
183
+ file counts. It may be called from worker threads, one at a time.
184
+ cancel: Stops the downloads and keeps partial files for later.
185
+ task: The task name reported to ``progress``.
186
+
187
+ Raises:
188
+ DownloadError: A file failed. `DownloadError.failures` lists every failure.
189
+ Cancelled: ``cancel`` was triggered.
190
+ """
191
+ items = [resources] if isinstance(resources, Resource) else list(resources)
192
+ unique = list({resource.path: resource for resource in items}.values())
193
+ if session is None:
194
+ with Session() as owned:
195
+ return download(
196
+ unique,
197
+ session=owned,
198
+ max_workers=max_workers,
199
+ progress=progress,
200
+ cancel=cancel,
201
+ task=task,
202
+ )
203
+
204
+ sizes = [resource.size for resource in unique]
205
+ by_bytes = bool(unique) and all(size is not None for size in sizes)
206
+ total_bytes = sum(size or 0 for size in sizes)
207
+ lock = threading.Lock()
208
+ transferred = 0
209
+
210
+ def on_bytes(count: int) -> None:
211
+ nonlocal transferred
212
+ if not by_bytes:
213
+ return
214
+ with lock:
215
+ transferred += count
216
+ done = min(max(transferred, 0), total_bytes)
217
+ report(progress, task, done, total_bytes, "byte")
218
+
219
+ failures: list[str] = []
220
+ finished = 0
221
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
222
+ futures: dict[Future[None], Resource] = {
223
+ executor.submit(_fetch, resource, session, on_bytes, cancel): resource
224
+ for resource in unique
225
+ }
226
+ for future in as_completed(futures):
227
+ error = future.exception()
228
+ if isinstance(error, Cancelled):
229
+ for other in futures:
230
+ other.cancel()
231
+ raise error
232
+ if isinstance(error, DownloadError):
233
+ failures.append(str(error))
234
+ elif error is not None:
235
+ raise error
236
+ finished += 1
237
+ if not by_bytes:
238
+ with lock:
239
+ report(progress, task, finished, len(unique), "file")
240
+ if failures:
241
+ raise DownloadError(
242
+ f"{len(failures)} of {len(unique)} downloads failed", failures
243
+ )