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
modsmith/packs/_model.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""The modpack model, which does not depend on any format or platform."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import zipfile
|
|
5
|
+
from collections.abc import Collection, Iterator, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from typing import IO, Literal
|
|
9
|
+
|
|
10
|
+
from .._support import write_atomic
|
|
11
|
+
from ..errors import PackError
|
|
12
|
+
from ..loaders import Loader, create
|
|
13
|
+
from ..net import Resource
|
|
14
|
+
|
|
15
|
+
# Which half of the game a file is for, and how much that side needs it.
|
|
16
|
+
Side = Literal["client", "server"]
|
|
17
|
+
Support = Literal["required", "optional", "unsupported"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def safe_relative(name: str) -> str:
|
|
21
|
+
"""Reject archive paths that could escape the target directory.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
name: The path inside a modpack.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The normalized path, with forward slashes.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
PackError: The path is absolute or could leave the directory.
|
|
31
|
+
"""
|
|
32
|
+
path = PurePosixPath(name)
|
|
33
|
+
if path.is_absolute() or ".." in path.parts or ":" in name or "\\" in name:
|
|
34
|
+
raise PackError(f"Unsafe path in modpack: {name}")
|
|
35
|
+
return path.as_posix()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class FileRef:
|
|
40
|
+
"""A file identified by a platform, not yet given a download.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
platform: The platform's name.
|
|
44
|
+
project_id: The project's id on that platform.
|
|
45
|
+
file_id: The file's id on that platform.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
platform: str
|
|
49
|
+
project_id: str
|
|
50
|
+
file_id: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class PackEntry:
|
|
55
|
+
"""A file a pack needs. It is resolved once `path` and `urls` are known.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
path: Where the file goes in the instance, or None if not yet known.
|
|
59
|
+
urls: Download locations, tried in order.
|
|
60
|
+
hashes: Expected digests keyed by algorithm name.
|
|
61
|
+
size: The expected size in bytes, or None if unknown.
|
|
62
|
+
client: Whether the client needs it.
|
|
63
|
+
server: Whether the server needs it.
|
|
64
|
+
ref: The platform file it refers to, or None.
|
|
65
|
+
manual_url: The page where a player can download it by hand, or None.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
path: str | None = None
|
|
69
|
+
urls: tuple[str, ...] = ()
|
|
70
|
+
hashes: Mapping[str, str] = field(default_factory=dict[str, str], hash=False)
|
|
71
|
+
size: int | None = None
|
|
72
|
+
client: Support = "required"
|
|
73
|
+
server: Support = "required"
|
|
74
|
+
ref: FileRef | None = None
|
|
75
|
+
manual_url: str | None = None
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def is_resolved(self) -> bool:
|
|
79
|
+
"""Whether the entry has a path and a download."""
|
|
80
|
+
return self.path is not None and bool(self.urls)
|
|
81
|
+
|
|
82
|
+
def support(self, side: Side) -> Support:
|
|
83
|
+
"""Return how ``side`` treats this entry."""
|
|
84
|
+
return self.client if side == "client" else self.server
|
|
85
|
+
|
|
86
|
+
def wanted(self, side: Side, include_optional: bool = False) -> bool:
|
|
87
|
+
"""Return whether ``side`` should install this entry.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
side: The side.
|
|
91
|
+
include_optional: Also accept optional entries.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
True if the entry should be installed.
|
|
95
|
+
"""
|
|
96
|
+
support = self.support(side)
|
|
97
|
+
return support == "required" or (include_optional and support == "optional")
|
|
98
|
+
|
|
99
|
+
def resource(self, root: Path) -> Resource:
|
|
100
|
+
"""Describe this entry's download.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
root: The instance directory.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The resource.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
PackError: The entry has no path yet.
|
|
110
|
+
"""
|
|
111
|
+
if self.path is None:
|
|
112
|
+
raise PackError("Entry has no target path")
|
|
113
|
+
return Resource(self.urls, root / self.path, dict(self.hashes), self.size)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class Modpack:
|
|
118
|
+
"""A parsed modpack archive, independent of its format and platform.
|
|
119
|
+
|
|
120
|
+
Attributes:
|
|
121
|
+
format: The archive format's name, such as ``mrpack``.
|
|
122
|
+
name: The pack's name.
|
|
123
|
+
version: The pack's version.
|
|
124
|
+
minecraft: The Minecraft version it needs.
|
|
125
|
+
loader_kind: The mod loader's name.
|
|
126
|
+
loader_version: The mod loader's version.
|
|
127
|
+
entries: The files it needs.
|
|
128
|
+
archive: The archive it was read from.
|
|
129
|
+
override_roots: Archive folders with extra files, keyed by ``common``,
|
|
130
|
+
``client`` or ``server``.
|
|
131
|
+
memory_mb: The recommended memory, or None if not declared.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
format: str
|
|
135
|
+
name: str
|
|
136
|
+
version: str
|
|
137
|
+
minecraft: str
|
|
138
|
+
loader_kind: str
|
|
139
|
+
loader_version: str
|
|
140
|
+
entries: tuple[PackEntry, ...]
|
|
141
|
+
archive: Path
|
|
142
|
+
override_roots: Mapping[str, str] = field(
|
|
143
|
+
default_factory=dict[str, str], hash=False
|
|
144
|
+
)
|
|
145
|
+
memory_mb: int | None = None
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def loader(self) -> Loader:
|
|
149
|
+
"""The pack's mod loader.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
UnsupportedError: The loader is not supported.
|
|
153
|
+
"""
|
|
154
|
+
return create(self.loader_kind, self.loader_version, self.minecraft)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def unresolved(self) -> tuple[PackEntry, ...]:
|
|
158
|
+
"""The entries that have no download yet."""
|
|
159
|
+
return tuple(entry for entry in self.entries if not entry.is_resolved)
|
|
160
|
+
|
|
161
|
+
def with_entries(self, entries: tuple[PackEntry, ...]) -> "Modpack":
|
|
162
|
+
"""Return a copy of this pack with different entries."""
|
|
163
|
+
return dataclasses.replace(self, entries=entries)
|
|
164
|
+
|
|
165
|
+
def override_members(self, side: Side = "client") -> dict[str, zipfile.ZipInfo]:
|
|
166
|
+
"""Return the pack's override files without reading their contents.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
side: The side to read.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Each file's archive entry, keyed by its path relative to the
|
|
173
|
+
instance. A side-specific file replaces a common one.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
PackError: A file's path is unsafe.
|
|
177
|
+
"""
|
|
178
|
+
roots = [self.override_roots.get("common"), self.override_roots.get(side)]
|
|
179
|
+
members: dict[str, zipfile.ZipInfo] = {}
|
|
180
|
+
with zipfile.ZipFile(self.archive) as archive:
|
|
181
|
+
for root in roots:
|
|
182
|
+
if not root:
|
|
183
|
+
continue
|
|
184
|
+
prefix = root.strip("/") + "/"
|
|
185
|
+
for info in archive.infolist():
|
|
186
|
+
if info.is_dir() or not info.filename.startswith(prefix):
|
|
187
|
+
continue
|
|
188
|
+
relative = safe_relative(info.filename.removeprefix(prefix))
|
|
189
|
+
members[relative] = info
|
|
190
|
+
return members
|
|
191
|
+
|
|
192
|
+
def iter_override_files(
|
|
193
|
+
self, side: Side = "client", only: Collection[str] | None = None
|
|
194
|
+
) -> Iterator[tuple[str, IO[bytes]]]:
|
|
195
|
+
"""Yield override files as open streams, so large files stay out of memory.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
side: The side to read.
|
|
199
|
+
only: The relative paths to read. Defaults to every file.
|
|
200
|
+
|
|
201
|
+
Yields:
|
|
202
|
+
Each file's path relative to the instance, and a stream of its
|
|
203
|
+
contents. The stream closes when the iteration moves on.
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
PackError: A file's path is unsafe.
|
|
207
|
+
"""
|
|
208
|
+
members = self.override_members(side)
|
|
209
|
+
with zipfile.ZipFile(self.archive) as archive:
|
|
210
|
+
for relative, info in members.items():
|
|
211
|
+
if only is None or relative in only:
|
|
212
|
+
with archive.open(info) as file:
|
|
213
|
+
yield relative, file
|
|
214
|
+
|
|
215
|
+
def iter_overrides(self, side: Side = "client") -> Iterator[tuple[str, bytes]]:
|
|
216
|
+
"""Yield the pack's override files with their contents.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
side: The side to read.
|
|
220
|
+
|
|
221
|
+
Yields:
|
|
222
|
+
Each file's path relative to the instance, and its contents.
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
PackError: A file's path is unsafe.
|
|
226
|
+
"""
|
|
227
|
+
for relative, file in self.iter_override_files(side):
|
|
228
|
+
yield relative, file.read()
|
|
229
|
+
|
|
230
|
+
def extract_overrides(self, dest: Path, side: Side = "client") -> list[str]:
|
|
231
|
+
"""Write the override files into ``dest`` and return their paths.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
dest: The instance directory.
|
|
235
|
+
side: The side to read.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The relative paths that were written.
|
|
239
|
+
|
|
240
|
+
Raises:
|
|
241
|
+
PackError: A file's path is unsafe.
|
|
242
|
+
"""
|
|
243
|
+
written: list[str] = []
|
|
244
|
+
for relative, file in self.iter_override_files(side):
|
|
245
|
+
write_atomic(dest / relative, file)
|
|
246
|
+
written.append(relative)
|
|
247
|
+
return written
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Search and fetch from mod platforms. Subclass `Platform` to add another."""
|
|
2
|
+
|
|
3
|
+
from ._base import (
|
|
4
|
+
Platform,
|
|
5
|
+
Project,
|
|
6
|
+
ProjectType,
|
|
7
|
+
Release,
|
|
8
|
+
ReleaseFile,
|
|
9
|
+
ReleaseType,
|
|
10
|
+
SearchResults,
|
|
11
|
+
)
|
|
12
|
+
from ._curseforge import CurseForge
|
|
13
|
+
from ._modrinth import Modrinth
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"CurseForge",
|
|
17
|
+
"Modrinth",
|
|
18
|
+
"Platform",
|
|
19
|
+
"Project",
|
|
20
|
+
"ProjectType",
|
|
21
|
+
"Release",
|
|
22
|
+
"ReleaseFile",
|
|
23
|
+
"ReleaseType",
|
|
24
|
+
"SearchResults",
|
|
25
|
+
]
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""The data types and interface shared by every platform."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import Iterator, Mapping
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import ClassVar, Literal, Self
|
|
9
|
+
|
|
10
|
+
from ..errors import PlatformError
|
|
11
|
+
from ..net import Resource, Session, download
|
|
12
|
+
from ..packs import Modpack
|
|
13
|
+
from ..progress import CancelToken, ProgressCallback
|
|
14
|
+
|
|
15
|
+
ProjectType = Literal["mod", "modpack", "resourcepack", "shader"]
|
|
16
|
+
ReleaseType = Literal["release", "beta", "alpha"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Project:
|
|
21
|
+
"""A mod, modpack or other project on a platform.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
platform: The platform's name.
|
|
25
|
+
id: The project's id on that platform.
|
|
26
|
+
slug: The project's short name.
|
|
27
|
+
name: The display name.
|
|
28
|
+
summary: A short description.
|
|
29
|
+
project_type: The kind of project, such as ``modpack``.
|
|
30
|
+
icon_url: The icon's URL, or None.
|
|
31
|
+
url: The project's web page, or None.
|
|
32
|
+
downloads: The number of downloads.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
platform: str
|
|
36
|
+
id: str
|
|
37
|
+
slug: str
|
|
38
|
+
name: str
|
|
39
|
+
summary: str = ""
|
|
40
|
+
project_type: str = "modpack"
|
|
41
|
+
icon_url: str | None = None
|
|
42
|
+
url: str | None = None
|
|
43
|
+
downloads: int = 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ReleaseFile:
|
|
48
|
+
"""One file of a release.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
name: The file name.
|
|
52
|
+
urls: Download locations. Empty if the file cannot be downloaded directly.
|
|
53
|
+
hashes: Expected digests keyed by algorithm name.
|
|
54
|
+
size: The size in bytes, or None if unknown.
|
|
55
|
+
primary: Whether this is the release's main file.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
urls: tuple[str, ...] = ()
|
|
60
|
+
hashes: Mapping[str, str] = field(default_factory=dict[str, str], hash=False)
|
|
61
|
+
size: int | None = None
|
|
62
|
+
primary: bool = True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Release:
|
|
67
|
+
"""One published version: a CurseForge File or a Modrinth Version.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
platform: The platform's name.
|
|
71
|
+
id: The release's id on that platform.
|
|
72
|
+
project_id: The project's id on that platform.
|
|
73
|
+
name: The display name.
|
|
74
|
+
release_type: One of ``release``, ``beta`` or ``alpha``.
|
|
75
|
+
published: The publication time, in ISO 8601 format.
|
|
76
|
+
game_versions: The Minecraft versions it supports.
|
|
77
|
+
loaders: The mod loaders it supports.
|
|
78
|
+
files: The files it contains.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
platform: str
|
|
82
|
+
id: str
|
|
83
|
+
project_id: str
|
|
84
|
+
name: str
|
|
85
|
+
release_type: ReleaseType = "release"
|
|
86
|
+
published: str = ""
|
|
87
|
+
game_versions: tuple[str, ...] = ()
|
|
88
|
+
loaders: tuple[str, ...] = ()
|
|
89
|
+
files: tuple[ReleaseFile, ...] = ()
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def primary_file(self) -> ReleaseFile:
|
|
93
|
+
"""The file marked as primary, or the first file.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
PlatformError: The release has no files.
|
|
97
|
+
"""
|
|
98
|
+
for file in self.files:
|
|
99
|
+
if file.primary:
|
|
100
|
+
return file
|
|
101
|
+
if not self.files:
|
|
102
|
+
raise PlatformError(f"Release {self.name} has no files")
|
|
103
|
+
return self.files[0]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class SearchResults:
|
|
108
|
+
"""One page of search results.
|
|
109
|
+
|
|
110
|
+
Attributes:
|
|
111
|
+
projects: The projects on this page.
|
|
112
|
+
total: The number of matches across all pages.
|
|
113
|
+
offset: The position of this page's first project.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
projects: tuple[Project, ...]
|
|
117
|
+
total: int
|
|
118
|
+
offset: int = 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def project_ref(ref: str | int) -> str:
|
|
122
|
+
"""Reduce an id, slug or project URL to the id or slug."""
|
|
123
|
+
return str(ref).rstrip("/").rsplit("/", 1)[-1]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Platform(ABC):
|
|
127
|
+
"""A catalogue of projects. Subclass it to support another platform.
|
|
128
|
+
|
|
129
|
+
Use it as a context manager, or call `close` when you are done.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
session: The session to use. If omitted, the platform creates its own and
|
|
133
|
+
closes it in `close`.
|
|
134
|
+
|
|
135
|
+
Attributes:
|
|
136
|
+
name: The platform's name.
|
|
137
|
+
session: The session in use.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
name: ClassVar[str]
|
|
141
|
+
|
|
142
|
+
def __init__(self, session: Session | None = None) -> None:
|
|
143
|
+
self._owns_session = session is None
|
|
144
|
+
self.session = session or Session()
|
|
145
|
+
|
|
146
|
+
def __enter__(self) -> Self:
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def __exit__(
|
|
150
|
+
self,
|
|
151
|
+
exc_type: type[BaseException] | None,
|
|
152
|
+
exc: BaseException | None,
|
|
153
|
+
traceback: TracebackType | None,
|
|
154
|
+
) -> None:
|
|
155
|
+
self.close()
|
|
156
|
+
|
|
157
|
+
def close(self) -> None:
|
|
158
|
+
"""Close the session, if the platform created it."""
|
|
159
|
+
if self._owns_session:
|
|
160
|
+
self.session.close()
|
|
161
|
+
|
|
162
|
+
@abstractmethod
|
|
163
|
+
def search(
|
|
164
|
+
self,
|
|
165
|
+
query: str,
|
|
166
|
+
*,
|
|
167
|
+
project_type: ProjectType = "modpack",
|
|
168
|
+
game_version: str | None = None,
|
|
169
|
+
loader: str | None = None,
|
|
170
|
+
offset: int = 0,
|
|
171
|
+
limit: int = 20,
|
|
172
|
+
) -> SearchResults:
|
|
173
|
+
"""Search the platform's catalogue.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
query: The search text.
|
|
177
|
+
project_type: The kind of project to find.
|
|
178
|
+
game_version: Only match this Minecraft version.
|
|
179
|
+
loader: Only match this mod loader.
|
|
180
|
+
offset: How many results to skip.
|
|
181
|
+
limit: The maximum number of results to return.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
One page of results.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
NetworkError: The platform could not be reached.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
@abstractmethod
|
|
191
|
+
def get_project(
|
|
192
|
+
self, ref: str | int, *, project_type: ProjectType = "modpack"
|
|
193
|
+
) -> Project:
|
|
194
|
+
"""Look up a project by id, slug or URL.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
ref: The project's id, slug or web address.
|
|
198
|
+
project_type: The kind of project.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
The project.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
NetworkError: The platform failed or does not know the project.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
@abstractmethod
|
|
208
|
+
def iter_releases(
|
|
209
|
+
self,
|
|
210
|
+
project: Project,
|
|
211
|
+
*,
|
|
212
|
+
game_version: str | None = None,
|
|
213
|
+
loader: str | None = None,
|
|
214
|
+
release_only: bool = False,
|
|
215
|
+
) -> Iterator[Release]:
|
|
216
|
+
"""Yield a project's releases, newest first, reading pages as needed.
|
|
217
|
+
|
|
218
|
+
Stopping early saves requests, so ``next()`` finds the latest release
|
|
219
|
+
with a single request. Use it while the platform is still open.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
project: The project.
|
|
223
|
+
game_version: Only list this Minecraft version.
|
|
224
|
+
loader: Only list this mod loader.
|
|
225
|
+
release_only: Leave out alpha and beta releases.
|
|
226
|
+
|
|
227
|
+
Yields:
|
|
228
|
+
Each release, once.
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
NetworkError: The platform could not be reached.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def list_releases(
|
|
235
|
+
self,
|
|
236
|
+
project: Project,
|
|
237
|
+
*,
|
|
238
|
+
game_version: str | None = None,
|
|
239
|
+
loader: str | None = None,
|
|
240
|
+
release_only: bool = False,
|
|
241
|
+
) -> list[Release]:
|
|
242
|
+
"""List all of a project's releases, newest first.
|
|
243
|
+
|
|
244
|
+
This reads every page. Use `iter_releases` if you need only some.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
project: The project.
|
|
248
|
+
game_version: Only list this Minecraft version.
|
|
249
|
+
loader: Only list this mod loader.
|
|
250
|
+
release_only: Leave out alpha and beta releases.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
The releases.
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
NetworkError: The platform could not be reached.
|
|
257
|
+
"""
|
|
258
|
+
releases = self.iter_releases(
|
|
259
|
+
project,
|
|
260
|
+
game_version=game_version,
|
|
261
|
+
loader=loader,
|
|
262
|
+
release_only=release_only,
|
|
263
|
+
)
|
|
264
|
+
return sorted(releases, key=lambda item: item.published, reverse=True)
|
|
265
|
+
|
|
266
|
+
def resolve(self, pack: Modpack) -> Modpack:
|
|
267
|
+
"""Fill in downloads for entries this platform can identify.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
pack: The modpack.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
The modpack with resolved entries. By default, it is returned unchanged.
|
|
274
|
+
"""
|
|
275
|
+
return pack
|
|
276
|
+
|
|
277
|
+
def fetch(
|
|
278
|
+
self,
|
|
279
|
+
release: Release,
|
|
280
|
+
into: Path,
|
|
281
|
+
*,
|
|
282
|
+
progress: ProgressCallback | None = None,
|
|
283
|
+
cancel: CancelToken | None = None,
|
|
284
|
+
) -> Path:
|
|
285
|
+
"""Download a release's primary file into a directory.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
release: The release.
|
|
289
|
+
into: The directory. It is created if needed.
|
|
290
|
+
progress: Receives download updates.
|
|
291
|
+
cancel: Stops the download.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
The downloaded file.
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
PlatformError: The file has no download link.
|
|
298
|
+
DownloadError: The download failed.
|
|
299
|
+
Cancelled: ``cancel`` was triggered.
|
|
300
|
+
"""
|
|
301
|
+
file = release.primary_file
|
|
302
|
+
if not file.urls:
|
|
303
|
+
raise PlatformError(f"{file.name} has no download link")
|
|
304
|
+
path = Path(into) / file.name
|
|
305
|
+
download(
|
|
306
|
+
Resource(file.urls, path, dict(file.hashes), file.size),
|
|
307
|
+
session=self.session,
|
|
308
|
+
progress=progress,
|
|
309
|
+
cancel=cancel,
|
|
310
|
+
task="fetch",
|
|
311
|
+
)
|
|
312
|
+
return path
|