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,118 @@
|
|
|
1
|
+
"""Download targets and hash checks."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import IO
|
|
8
|
+
|
|
9
|
+
HASH_PRIORITY = ("sha512", "sha256", "sha1", "md5")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def file_hash(path: Path, algorithm: str = "sha1") -> str:
|
|
13
|
+
"""Hash a file without loading it all into memory.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
path: The file.
|
|
17
|
+
algorithm: A name that `hashlib.new` accepts.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The hexadecimal digest.
|
|
21
|
+
"""
|
|
22
|
+
with open(path, "rb") as file:
|
|
23
|
+
return stream_hash(file, algorithm)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def stream_hash(file: IO[bytes], algorithm: str = "sha1") -> str:
|
|
27
|
+
"""Hash an open file without loading it all into memory.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
file: The open file.
|
|
31
|
+
algorithm: A name that `hashlib.new` accepts.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The hexadecimal digest.
|
|
35
|
+
"""
|
|
36
|
+
digest = hashlib.new(algorithm)
|
|
37
|
+
for chunk in iter(lambda: file.read(1 << 20), b""):
|
|
38
|
+
digest.update(chunk)
|
|
39
|
+
return digest.hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def strongest_hash(hashes: Mapping[str, str]) -> tuple[str, str] | None:
|
|
43
|
+
"""Pick the strongest known hash.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
hashes: Digests keyed by algorithm name.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The algorithm and its lowercase digest, or None if none is known.
|
|
50
|
+
"""
|
|
51
|
+
for algorithm in HASH_PRIORITY:
|
|
52
|
+
value = hashes.get(algorithm)
|
|
53
|
+
if value:
|
|
54
|
+
return algorithm, value.lower()
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def matches(path: Path, hashes: Mapping[str, str]) -> bool:
|
|
59
|
+
"""Check a file against its strongest hash.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
path: The file.
|
|
63
|
+
hashes: Digests keyed by algorithm name.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True if the digest matches, or if no known hash was given.
|
|
67
|
+
"""
|
|
68
|
+
expected = strongest_hash(hashes)
|
|
69
|
+
return expected is None or file_hash(path, expected[0]) == expected[1]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Resource:
|
|
74
|
+
"""A file to download: where from, where to, and how to verify it.
|
|
75
|
+
|
|
76
|
+
Attributes:
|
|
77
|
+
urls: Download locations, tried in order.
|
|
78
|
+
path: The destination.
|
|
79
|
+
hashes: Expected digests keyed by algorithm name.
|
|
80
|
+
size: The expected size in bytes, or None if unknown.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
urls: tuple[str, ...]
|
|
84
|
+
path: Path
|
|
85
|
+
hashes: Mapping[str, str] = field(default_factory=dict[str, str], hash=False)
|
|
86
|
+
size: int | None = None
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def of(
|
|
90
|
+
cls,
|
|
91
|
+
url: str,
|
|
92
|
+
path: Path,
|
|
93
|
+
hashes: Mapping[str, str] | None = None,
|
|
94
|
+
size: int | None = None,
|
|
95
|
+
) -> "Resource":
|
|
96
|
+
"""Create a resource with one URL.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
url: The download location.
|
|
100
|
+
path: The destination.
|
|
101
|
+
hashes: Expected digests keyed by algorithm name.
|
|
102
|
+
size: The expected size in bytes, or None if unknown.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
The resource.
|
|
106
|
+
"""
|
|
107
|
+
return cls((url,), path, dict(hashes or {}), size)
|
|
108
|
+
|
|
109
|
+
def is_present(self) -> bool:
|
|
110
|
+
"""Return whether the destination already passes the size and hash checks.
|
|
111
|
+
|
|
112
|
+
With no size or hashes to check, any existing file counts as present.
|
|
113
|
+
"""
|
|
114
|
+
if not self.path.is_file():
|
|
115
|
+
return False
|
|
116
|
+
if self.size is not None and self.path.stat().st_size != self.size:
|
|
117
|
+
return False
|
|
118
|
+
return matches(self.path, self.hashes)
|
modsmith/net/_session.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""HTTP session with retries, shared by everything that uses the network."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Collection, Generator, Mapping
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any, Self
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from ..errors import NetworkError
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
__version__ = version("modsmith")
|
|
17
|
+
except PackageNotFoundError:
|
|
18
|
+
__version__ = "0.0.0"
|
|
19
|
+
|
|
20
|
+
DEFAULT_USER_AGENT = f"modsmith/{__version__} (https://github.com/PaPa-jun/ModSmith)"
|
|
21
|
+
DEFAULT_TIMEOUT = (10.0, 60.0)
|
|
22
|
+
MAX_RETRY_AFTER = 60.0
|
|
23
|
+
RETRY_STATUS = frozenset({429, 500, 502, 503, 504})
|
|
24
|
+
TRANSIENT_ERRORS = (
|
|
25
|
+
requests.ConnectionError,
|
|
26
|
+
requests.Timeout,
|
|
27
|
+
requests.exceptions.ChunkedEncodingError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_retryable(error: Exception) -> bool:
|
|
32
|
+
"""Return whether a failed request is worth repeating.
|
|
33
|
+
|
|
34
|
+
Connection errors, timeouts, and HTTP 429 and 5xx responses are transient.
|
|
35
|
+
"""
|
|
36
|
+
if isinstance(error, NetworkError):
|
|
37
|
+
return error.status is None or error.status in RETRY_STATUS
|
|
38
|
+
return isinstance(error, TRANSIENT_ERRORS)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@contextmanager
|
|
42
|
+
def session_scope(session: "Session | None") -> Generator["Session"]:
|
|
43
|
+
"""Yield ``session``, or a temporary `Session` closed on exit."""
|
|
44
|
+
if session is not None:
|
|
45
|
+
yield session
|
|
46
|
+
return
|
|
47
|
+
with Session() as owned:
|
|
48
|
+
yield owned
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def retry_delay(headers: Mapping[str, str]) -> float | None:
|
|
52
|
+
"""Return the delay a ``Retry-After`` header asks for, capped at one minute.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
headers: The response headers.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The delay in seconds, or None if there is no delay in seconds.
|
|
59
|
+
"""
|
|
60
|
+
value = headers.get("Retry-After", "")
|
|
61
|
+
return min(float(value), MAX_RETRY_AFTER) if value.isdigit() else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Session:
|
|
65
|
+
"""HTTP client with retries. Safe to share between threads.
|
|
66
|
+
|
|
67
|
+
Each thread gets its own connection pool. Use it as a context manager, or
|
|
68
|
+
call `close` when you are done.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
user_agent: The ``User-Agent`` header sent with every request.
|
|
72
|
+
attempts: How many times to try each request.
|
|
73
|
+
backoff: Seconds to wait before the first retry. Doubles on each retry.
|
|
74
|
+
timeout: The connect and read timeouts in seconds.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
80
|
+
attempts: int = 4,
|
|
81
|
+
backoff: float = 1.0,
|
|
82
|
+
timeout: tuple[float, float] = DEFAULT_TIMEOUT,
|
|
83
|
+
) -> None:
|
|
84
|
+
self.user_agent = user_agent
|
|
85
|
+
self.attempts = attempts
|
|
86
|
+
self.backoff = backoff
|
|
87
|
+
self.timeout = timeout
|
|
88
|
+
self._local = threading.local()
|
|
89
|
+
self._lock = threading.Lock()
|
|
90
|
+
self._sessions: list[requests.Session] = []
|
|
91
|
+
|
|
92
|
+
def __enter__(self) -> Self:
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def __exit__(
|
|
96
|
+
self,
|
|
97
|
+
exc_type: type[BaseException] | None,
|
|
98
|
+
exc: BaseException | None,
|
|
99
|
+
traceback: TracebackType | None,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.close()
|
|
102
|
+
|
|
103
|
+
def close(self) -> None:
|
|
104
|
+
"""Close the connections of every thread.
|
|
105
|
+
|
|
106
|
+
The session stays usable and reconnects on the next request.
|
|
107
|
+
"""
|
|
108
|
+
with self._lock:
|
|
109
|
+
for session in self._sessions:
|
|
110
|
+
session.close()
|
|
111
|
+
self._sessions.clear()
|
|
112
|
+
self._local = threading.local()
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def http(self) -> requests.Session:
|
|
116
|
+
"""The calling thread's `requests.Session`, created on first use."""
|
|
117
|
+
session: requests.Session | None = getattr(self._local, "session", None)
|
|
118
|
+
if session is None:
|
|
119
|
+
session = requests.Session()
|
|
120
|
+
session.headers["User-Agent"] = self.user_agent
|
|
121
|
+
self._local.session = session
|
|
122
|
+
with self._lock:
|
|
123
|
+
self._sessions.append(session)
|
|
124
|
+
return session
|
|
125
|
+
|
|
126
|
+
def wait(self, attempt: int, delay: float | None = None) -> None:
|
|
127
|
+
"""Sleep before a retry.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
attempt: The zero-based index of the retry.
|
|
131
|
+
delay: Seconds the server asked for, instead of the normal backoff.
|
|
132
|
+
"""
|
|
133
|
+
time.sleep(self.backoff * 2**attempt if delay is None else delay)
|
|
134
|
+
|
|
135
|
+
def request(
|
|
136
|
+
self, method: str, url: str, *, accept: Collection[int] = (), **kwargs: Any
|
|
137
|
+
) -> requests.Response:
|
|
138
|
+
"""Send a request, retrying transient failures.
|
|
139
|
+
|
|
140
|
+
Retries stop once a response has been accepted, so reading a streamed
|
|
141
|
+
body is not covered. A delay in ``Retry-After`` is respected, up to one
|
|
142
|
+
minute.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
method: The HTTP method.
|
|
146
|
+
url: The URL.
|
|
147
|
+
accept: Error statuses to return instead of raising.
|
|
148
|
+
**kwargs: Passed to `requests.Session.request`.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
A successful response, or one with a status in ``accept``.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
NetworkError: The last attempt failed or returned an error status.
|
|
155
|
+
"""
|
|
156
|
+
kwargs.setdefault("timeout", self.timeout)
|
|
157
|
+
last = NetworkError(f"{method} {url} was not attempted")
|
|
158
|
+
delay: float | None = None
|
|
159
|
+
for attempt in range(self.attempts):
|
|
160
|
+
if attempt:
|
|
161
|
+
self.wait(attempt - 1, delay)
|
|
162
|
+
delay = None
|
|
163
|
+
try:
|
|
164
|
+
response = self.http.request(method, url, **kwargs)
|
|
165
|
+
except requests.RequestException as e:
|
|
166
|
+
last = NetworkError(f"{method} {url} failed: {e}")
|
|
167
|
+
if not is_retryable(e):
|
|
168
|
+
break
|
|
169
|
+
continue
|
|
170
|
+
if response.ok or response.status_code in accept:
|
|
171
|
+
return response
|
|
172
|
+
delay = retry_delay(response.headers)
|
|
173
|
+
response.close()
|
|
174
|
+
last = NetworkError(
|
|
175
|
+
f"{method} {url} returned {response.status_code}", response.status_code
|
|
176
|
+
)
|
|
177
|
+
if not is_retryable(last):
|
|
178
|
+
break
|
|
179
|
+
raise last
|
|
180
|
+
|
|
181
|
+
def get_json(self, url: str, **kwargs: Any) -> Any:
|
|
182
|
+
"""Send a GET request and decode the JSON response.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
url: The URL.
|
|
186
|
+
**kwargs: Passed to `requests.Session.request`.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The decoded body.
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
NetworkError: The request failed or the body is not JSON.
|
|
193
|
+
"""
|
|
194
|
+
return self._json(self.request("GET", url, **kwargs), url)
|
|
195
|
+
|
|
196
|
+
def post_json(self, url: str, **kwargs: Any) -> Any:
|
|
197
|
+
"""Send a POST request and decode the JSON response.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
url: The URL.
|
|
201
|
+
**kwargs: Passed to `requests.Session.request`.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The decoded body.
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
NetworkError: The request failed or the body is not JSON.
|
|
208
|
+
"""
|
|
209
|
+
return self._json(self.request("POST", url, **kwargs), url)
|
|
210
|
+
|
|
211
|
+
def stream(
|
|
212
|
+
self,
|
|
213
|
+
url: str,
|
|
214
|
+
headers: Mapping[str, str] | None = None,
|
|
215
|
+
*,
|
|
216
|
+
accept: Collection[int] = (),
|
|
217
|
+
) -> requests.Response:
|
|
218
|
+
"""Open a streaming GET request, with the same retries as `request`.
|
|
219
|
+
|
|
220
|
+
Failures while reading the body are not retried, because the caller
|
|
221
|
+
has to decide how to resume.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
url: The URL.
|
|
225
|
+
headers: Extra request headers.
|
|
226
|
+
accept: Error statuses to return instead of raising.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
The open response. Use it as a context manager.
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
NetworkError: The last attempt failed or returned an error status.
|
|
233
|
+
"""
|
|
234
|
+
return self.request("GET", url, accept=accept, headers=headers, stream=True)
|
|
235
|
+
|
|
236
|
+
def _json(self, response: requests.Response, url: str) -> Any:
|
|
237
|
+
"""Decode a JSON response, raising `NetworkError` if it is invalid."""
|
|
238
|
+
try:
|
|
239
|
+
return response.json()
|
|
240
|
+
except ValueError as e:
|
|
241
|
+
raise NetworkError(f"Invalid JSON from {url}") from e
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Read modpack archives. `load` detects the format from the archive."""
|
|
2
|
+
|
|
3
|
+
from ._formats import CurseForgePack, Mrpack, PackFormat, load, register
|
|
4
|
+
from ._model import FileRef, Modpack, PackEntry, Side, Support, safe_relative
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"CurseForgePack",
|
|
8
|
+
"FileRef",
|
|
9
|
+
"Modpack",
|
|
10
|
+
"Mrpack",
|
|
11
|
+
"PackEntry",
|
|
12
|
+
"PackFormat",
|
|
13
|
+
"Side",
|
|
14
|
+
"Support",
|
|
15
|
+
"load",
|
|
16
|
+
"register",
|
|
17
|
+
"safe_relative",
|
|
18
|
+
]
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Modpack archive formats, and detecting which one an archive uses."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import zipfile
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, ClassVar, Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
10
|
+
|
|
11
|
+
from ..errors import PackError, UnsupportedError
|
|
12
|
+
from ._model import FileRef, Modpack, PackEntry, Support, safe_relative
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PackFormat(ABC):
|
|
16
|
+
"""Reads one archive format. Subclass it and `register` it to add another.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
name: The format's name.
|
|
20
|
+
index_file: The file in the archive that identifies this format.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name: ClassVar[str]
|
|
24
|
+
index_file: ClassVar[str]
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def parse(self, index: dict[str, Any], path: Path) -> Modpack:
|
|
28
|
+
"""Build a `Modpack` from the archive's decoded index file.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
index: The decoded index file.
|
|
32
|
+
path: The archive.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The modpack.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
PackError: The index is invalid.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_formats: list[PackFormat] = []
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def register(format_: PackFormat) -> PackFormat:
|
|
46
|
+
"""Add a format. The most recently registered format is tried first.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
format_: The format.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The same format.
|
|
53
|
+
"""
|
|
54
|
+
_formats.insert(0, format_)
|
|
55
|
+
return format_
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load(path: Path | str) -> Modpack:
|
|
59
|
+
"""Read a modpack archive. Needs no network and no API key.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
path: The archive.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The modpack. CurseForge packs still need `Platform.resolve`.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
PackError: The archive is invalid or its format is unknown.
|
|
69
|
+
UnsupportedError: The pack is for another game.
|
|
70
|
+
"""
|
|
71
|
+
archive_path = Path(path)
|
|
72
|
+
try:
|
|
73
|
+
with zipfile.ZipFile(archive_path) as archive:
|
|
74
|
+
names = set(archive.namelist())
|
|
75
|
+
for format_ in _formats:
|
|
76
|
+
if format_.index_file in names:
|
|
77
|
+
index = json.loads(archive.read(format_.index_file))
|
|
78
|
+
return format_.parse(index, archive_path)
|
|
79
|
+
except (OSError, zipfile.BadZipFile, ValueError) as e:
|
|
80
|
+
raise PackError(f"{archive_path.name} is not a valid modpack: {e}") from e
|
|
81
|
+
raise PackError(f"{archive_path.name} is not a known modpack format")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _MrpackEnv(BaseModel):
|
|
85
|
+
"""A file's ``env`` object in ``modrinth.index.json``."""
|
|
86
|
+
|
|
87
|
+
client: Support = "required"
|
|
88
|
+
server: Support = "required"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _MrpackFile(BaseModel):
|
|
92
|
+
"""An entry of ``files`` in ``modrinth.index.json``."""
|
|
93
|
+
|
|
94
|
+
path: str
|
|
95
|
+
hashes: dict[str, str]
|
|
96
|
+
downloads: list[str]
|
|
97
|
+
file_size: int | None = Field(default=None, alias="fileSize")
|
|
98
|
+
env: _MrpackEnv = Field(default_factory=_MrpackEnv)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _MrpackIndex(BaseModel):
|
|
102
|
+
"""The contents of ``modrinth.index.json``."""
|
|
103
|
+
|
|
104
|
+
format_version: int = Field(alias="formatVersion")
|
|
105
|
+
game: str
|
|
106
|
+
name: str
|
|
107
|
+
version_id: str = Field(alias="versionId")
|
|
108
|
+
files: list[_MrpackFile]
|
|
109
|
+
dependencies: dict[str, str]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Mrpack(PackFormat):
|
|
113
|
+
"""The Modrinth ``.mrpack`` format.
|
|
114
|
+
|
|
115
|
+
Attributes:
|
|
116
|
+
roots: The folders that hold overrides, keyed by side.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
name = "mrpack"
|
|
120
|
+
index_file = "modrinth.index.json"
|
|
121
|
+
roots: ClassVar[dict[str, str]] = {
|
|
122
|
+
"common": "overrides",
|
|
123
|
+
"client": "client-overrides",
|
|
124
|
+
"server": "server-overrides",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
def parse(self, index: dict[str, Any], path: Path) -> Modpack:
|
|
128
|
+
"""Build a `Modpack` from ``modrinth.index.json``.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
index: The decoded index file.
|
|
132
|
+
path: The archive.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
The modpack. Its entries are already resolved.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
PackError: The index is invalid or does not name a game version or loader.
|
|
139
|
+
UnsupportedError: The pack is for another game.
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
data = _MrpackIndex.model_validate(index)
|
|
143
|
+
except ValidationError as e:
|
|
144
|
+
raise PackError(f"Invalid {self.index_file}: {e}") from e
|
|
145
|
+
if data.game != "minecraft":
|
|
146
|
+
raise UnsupportedError(f"Unsupported game: {data.game}")
|
|
147
|
+
dependencies = dict(data.dependencies)
|
|
148
|
+
minecraft = dependencies.pop("minecraft", None)
|
|
149
|
+
if minecraft is None:
|
|
150
|
+
raise PackError("Modpack does not declare a Minecraft version")
|
|
151
|
+
if not dependencies:
|
|
152
|
+
raise PackError("Modpack does not declare a mod loader")
|
|
153
|
+
loader_kind, loader_version = next(iter(dependencies.items()))
|
|
154
|
+
return Modpack(
|
|
155
|
+
format=self.name,
|
|
156
|
+
name=data.name,
|
|
157
|
+
version=data.version_id,
|
|
158
|
+
minecraft=minecraft,
|
|
159
|
+
loader_kind=loader_kind,
|
|
160
|
+
loader_version=loader_version,
|
|
161
|
+
entries=tuple(
|
|
162
|
+
PackEntry(
|
|
163
|
+
path=safe_relative(file.path),
|
|
164
|
+
urls=tuple(file.downloads),
|
|
165
|
+
hashes=file.hashes,
|
|
166
|
+
size=file.file_size,
|
|
167
|
+
client=file.env.client,
|
|
168
|
+
server=file.env.server,
|
|
169
|
+
)
|
|
170
|
+
for file in data.files
|
|
171
|
+
),
|
|
172
|
+
archive=path,
|
|
173
|
+
override_roots=self.roots,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _CfFile(BaseModel):
|
|
178
|
+
"""An entry of ``files`` in a CurseForge ``manifest.json``."""
|
|
179
|
+
|
|
180
|
+
project_id: int = Field(alias="projectID")
|
|
181
|
+
file_id: int = Field(alias="fileID")
|
|
182
|
+
required: bool = True
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class _CfLoader(BaseModel):
|
|
186
|
+
"""An entry of ``modLoaders`` in a CurseForge ``manifest.json``."""
|
|
187
|
+
|
|
188
|
+
id: str
|
|
189
|
+
primary: bool = False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class _CfMinecraft(BaseModel):
|
|
193
|
+
"""The ``minecraft`` object in a CurseForge ``manifest.json``."""
|
|
194
|
+
|
|
195
|
+
version: str
|
|
196
|
+
mod_loaders: list[_CfLoader] = Field(alias="modLoaders")
|
|
197
|
+
recommended_ram: int | None = Field(default=None, alias="recommendedRam")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class _CfManifest(BaseModel):
|
|
201
|
+
"""The contents of a CurseForge ``manifest.json``."""
|
|
202
|
+
|
|
203
|
+
manifest_type: Literal["minecraftModpack"] = Field(alias="manifestType")
|
|
204
|
+
name: str = "Modpack"
|
|
205
|
+
version: str = ""
|
|
206
|
+
overrides: str = "overrides"
|
|
207
|
+
minecraft: _CfMinecraft
|
|
208
|
+
files: list[_CfFile] = Field(default_factory=list[_CfFile])
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class CurseForgePack(PackFormat):
|
|
212
|
+
"""The CurseForge ``.zip`` format."""
|
|
213
|
+
|
|
214
|
+
name = "curseforge"
|
|
215
|
+
index_file = "manifest.json"
|
|
216
|
+
|
|
217
|
+
def parse(self, index: dict[str, Any], path: Path) -> Modpack:
|
|
218
|
+
"""Build a `Modpack` from ``manifest.json``.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
index: The decoded index file.
|
|
222
|
+
path: The archive.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
The modpack. Its entries need `CurseForge.resolve`.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
PackError: The manifest is invalid or does not name a mod loader.
|
|
229
|
+
"""
|
|
230
|
+
try:
|
|
231
|
+
data = _CfManifest.model_validate(index)
|
|
232
|
+
except ValidationError as e:
|
|
233
|
+
raise PackError(f"Invalid {self.index_file}: {e}") from e
|
|
234
|
+
loaders = data.minecraft.mod_loaders
|
|
235
|
+
if not loaders:
|
|
236
|
+
raise PackError("Modpack does not declare a mod loader")
|
|
237
|
+
loader = next((item for item in loaders if item.primary), loaders[0])
|
|
238
|
+
kind, _, version = loader.id.partition("-")
|
|
239
|
+
if not version:
|
|
240
|
+
raise PackError(f"Invalid mod loader id: {loader.id}")
|
|
241
|
+
|
|
242
|
+
def entry(file: _CfFile) -> PackEntry:
|
|
243
|
+
support: Support = "required" if file.required else "optional"
|
|
244
|
+
ref = FileRef(self.name, str(file.project_id), str(file.file_id))
|
|
245
|
+
return PackEntry(client=support, server=support, ref=ref)
|
|
246
|
+
|
|
247
|
+
return Modpack(
|
|
248
|
+
format=self.name,
|
|
249
|
+
name=data.name,
|
|
250
|
+
version=data.version,
|
|
251
|
+
minecraft=data.minecraft.version,
|
|
252
|
+
loader_kind=kind,
|
|
253
|
+
loader_version=version,
|
|
254
|
+
entries=tuple(entry(file) for file in data.files),
|
|
255
|
+
archive=path,
|
|
256
|
+
override_roots={"common": data.overrides},
|
|
257
|
+
memory_mb=data.minecraft.recommended_ram,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
register(CurseForgePack())
|
|
262
|
+
register(Mrpack())
|