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,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