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,390 @@
|
|
|
1
|
+
"""The CurseForge platform."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from typing import Any, ClassVar, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
from pydantic.alias_generators import to_camel
|
|
9
|
+
|
|
10
|
+
from ..errors import PlatformError
|
|
11
|
+
from ..net import Session
|
|
12
|
+
from ..packs import Modpack, PackEntry
|
|
13
|
+
from ._base import (
|
|
14
|
+
Platform,
|
|
15
|
+
Project,
|
|
16
|
+
ProjectType,
|
|
17
|
+
Release,
|
|
18
|
+
ReleaseFile,
|
|
19
|
+
ReleaseType,
|
|
20
|
+
SearchResults,
|
|
21
|
+
project_ref,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
API_URL = "https://api.curseforge.com"
|
|
25
|
+
# Minecraft's id on CurseForge.
|
|
26
|
+
GAME_ID = 432
|
|
27
|
+
# The most ids to send in one request.
|
|
28
|
+
BATCH_SIZE = 500
|
|
29
|
+
PAGE_SIZE = 50
|
|
30
|
+
# CurseForge rejects requests where index + pageSize is larger than this.
|
|
31
|
+
MAX_RESULTS = 10000
|
|
32
|
+
# CurseForge's numeric ids for hash algorithms, release types and categories.
|
|
33
|
+
HASH_ALGORITHMS = {1: "sha1", 2: "md5"}
|
|
34
|
+
RELEASE_TYPES: dict[int, ReleaseType] = {1: "release", 2: "beta", 3: "alpha"}
|
|
35
|
+
PROJECT_CLASSES: dict[str, int] = {
|
|
36
|
+
"mod": 6,
|
|
37
|
+
"modpack": 4471,
|
|
38
|
+
"resourcepack": 12,
|
|
39
|
+
"shader": 6552,
|
|
40
|
+
}
|
|
41
|
+
CLASS_FOLDERS = {
|
|
42
|
+
6: "mods",
|
|
43
|
+
12: "resourcepacks",
|
|
44
|
+
17: "saves",
|
|
45
|
+
4546: "config",
|
|
46
|
+
6552: "shaderpacks",
|
|
47
|
+
6945: "datapacks",
|
|
48
|
+
}
|
|
49
|
+
LOADER_TYPES = {"forge": 1, "fabric": 4, "quilt": 5, "neoforge": 6}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Schema(BaseModel):
|
|
53
|
+
"""Base for CurseForge API models, which use camelCase names."""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Hash(_Schema):
|
|
59
|
+
"""A file hash from the CurseForge API."""
|
|
60
|
+
|
|
61
|
+
value: str
|
|
62
|
+
algo: int
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _File(_Schema):
|
|
66
|
+
"""A file from the CurseForge API."""
|
|
67
|
+
|
|
68
|
+
id: int
|
|
69
|
+
mod_id: int
|
|
70
|
+
display_name: str = ""
|
|
71
|
+
file_name: str
|
|
72
|
+
release_type: Literal[1, 2, 3] = 1
|
|
73
|
+
file_date: str = ""
|
|
74
|
+
file_length: int | None = None
|
|
75
|
+
download_url: str | None = None
|
|
76
|
+
hashes: list[_Hash] = Field(default_factory=list[_Hash])
|
|
77
|
+
is_server_pack: bool | None = False
|
|
78
|
+
game_versions: list[str] = Field(default_factory=list[str])
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def hash_map(self) -> dict[str, str]:
|
|
82
|
+
"""The digests keyed by algorithm name."""
|
|
83
|
+
return {
|
|
84
|
+
HASH_ALGORITHMS[item.algo]: item.value
|
|
85
|
+
for item in self.hashes
|
|
86
|
+
if item.algo in HASH_ALGORITHMS
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
def to_release(self, platform: str) -> Release:
|
|
90
|
+
"""Convert this file to a `Release`."""
|
|
91
|
+
urls = (self.download_url,) if self.download_url else ()
|
|
92
|
+
return Release(
|
|
93
|
+
platform=platform,
|
|
94
|
+
id=str(self.id),
|
|
95
|
+
project_id=str(self.mod_id),
|
|
96
|
+
name=self.display_name or self.file_name,
|
|
97
|
+
release_type=RELEASE_TYPES[self.release_type],
|
|
98
|
+
published=self.file_date,
|
|
99
|
+
game_versions=tuple(self.game_versions),
|
|
100
|
+
files=(ReleaseFile(self.file_name, urls, self.hash_map, self.file_length),),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _Logo(_Schema):
|
|
105
|
+
"""A project's logo from the CurseForge API."""
|
|
106
|
+
|
|
107
|
+
url: str | None = None
|
|
108
|
+
thumbnail_url: str | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _Links(_Schema):
|
|
112
|
+
"""A project's links from the CurseForge API."""
|
|
113
|
+
|
|
114
|
+
website_url: str | None = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _Mod(_Schema):
|
|
118
|
+
"""A project from the CurseForge API."""
|
|
119
|
+
|
|
120
|
+
id: int
|
|
121
|
+
name: str
|
|
122
|
+
slug: str
|
|
123
|
+
summary: str = ""
|
|
124
|
+
class_id: int | None = None
|
|
125
|
+
logo: _Logo | None = None
|
|
126
|
+
links: _Links | None = None
|
|
127
|
+
allow_mod_distribution: bool | None = None
|
|
128
|
+
download_count: int = 0
|
|
129
|
+
|
|
130
|
+
def to_project(self, platform: str, project_type: str) -> Project:
|
|
131
|
+
"""Convert this mod to a `Project`."""
|
|
132
|
+
logo = self.logo
|
|
133
|
+
return Project(
|
|
134
|
+
platform=platform,
|
|
135
|
+
id=str(self.id),
|
|
136
|
+
slug=self.slug,
|
|
137
|
+
name=self.name,
|
|
138
|
+
summary=self.summary,
|
|
139
|
+
project_type=project_type,
|
|
140
|
+
icon_url=(logo.thumbnail_url or logo.url) if logo else None,
|
|
141
|
+
url=self.links.website_url if self.links else None,
|
|
142
|
+
downloads=self.download_count,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class CurseForge(Platform):
|
|
147
|
+
"""CurseForge. Requires an API key from console.curseforge.com.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
api_key: The API key.
|
|
151
|
+
session: The session to use. If omitted, the platform creates its own.
|
|
152
|
+
base_url: The API's base URL.
|
|
153
|
+
|
|
154
|
+
Attributes:
|
|
155
|
+
api_key: The API key.
|
|
156
|
+
base_url: The API's base URL.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
PlatformError: The API key is empty.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
name: ClassVar[str] = "curseforge"
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
api_key: str,
|
|
167
|
+
*,
|
|
168
|
+
session: Session | None = None,
|
|
169
|
+
base_url: str = API_URL,
|
|
170
|
+
) -> None:
|
|
171
|
+
super().__init__(session)
|
|
172
|
+
if not api_key:
|
|
173
|
+
raise PlatformError("CurseForge requires an API key")
|
|
174
|
+
self.api_key = api_key
|
|
175
|
+
self.base_url = base_url.rstrip("/")
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def _headers(self) -> dict[str, str]:
|
|
179
|
+
"""The headers sent with every request."""
|
|
180
|
+
return {"Accept": "application/json", "x-api-key": self.api_key}
|
|
181
|
+
|
|
182
|
+
def _get(self, path: str, **params: Any) -> Any:
|
|
183
|
+
"""Send a GET request to the API."""
|
|
184
|
+
return self.session.get_json(
|
|
185
|
+
self.base_url + path, headers=self._headers, params=params
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _post(self, path: str, payload: dict[str, Any]) -> Any:
|
|
189
|
+
"""Send a POST request to the API."""
|
|
190
|
+
return self.session.post_json(
|
|
191
|
+
self.base_url + path, headers=self._headers, json=payload
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def _search(self, project_type: str, **params: Any) -> Any:
|
|
195
|
+
"""Search the Minecraft projects of one type."""
|
|
196
|
+
return self._get(
|
|
197
|
+
"/v1/mods/search",
|
|
198
|
+
gameId=GAME_ID,
|
|
199
|
+
classId=PROJECT_CLASSES[project_type],
|
|
200
|
+
**params,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def search(
|
|
204
|
+
self,
|
|
205
|
+
query: str,
|
|
206
|
+
*,
|
|
207
|
+
project_type: ProjectType = "modpack",
|
|
208
|
+
game_version: str | None = None,
|
|
209
|
+
loader: str | None = None,
|
|
210
|
+
offset: int = 0,
|
|
211
|
+
limit: int = 20,
|
|
212
|
+
) -> SearchResults:
|
|
213
|
+
"""Search CurseForge's catalogue.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
query: The search text.
|
|
217
|
+
project_type: The kind of project to find.
|
|
218
|
+
game_version: Only match this Minecraft version.
|
|
219
|
+
loader: Only match this mod loader.
|
|
220
|
+
offset: How many results to skip.
|
|
221
|
+
limit: The maximum number of results to return.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
One page of results.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
NetworkError: CurseForge could not be reached.
|
|
228
|
+
"""
|
|
229
|
+
data = self._search(
|
|
230
|
+
project_type,
|
|
231
|
+
searchFilter=query,
|
|
232
|
+
gameVersion=game_version,
|
|
233
|
+
modLoaderType=LOADER_TYPES.get(loader or ""),
|
|
234
|
+
index=offset,
|
|
235
|
+
pageSize=limit,
|
|
236
|
+
)
|
|
237
|
+
mods = [_Mod.model_validate(item) for item in data["data"]]
|
|
238
|
+
projects = tuple(mod.to_project(self.name, project_type) for mod in mods)
|
|
239
|
+
pagination: dict[str, Any] = data.get("pagination") or {}
|
|
240
|
+
return SearchResults(projects, pagination.get("totalCount", len(mods)), offset)
|
|
241
|
+
|
|
242
|
+
def get_project(
|
|
243
|
+
self, ref: str | int, *, project_type: ProjectType = "modpack"
|
|
244
|
+
) -> Project:
|
|
245
|
+
"""Look up a project by id, slug or URL.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
ref: The project's id, slug or web address.
|
|
249
|
+
project_type: The kind of project. Slugs are only unique within a type.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
The project.
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
PlatformError: No project has that slug.
|
|
256
|
+
NetworkError: CurseForge could not be reached or does not know the id.
|
|
257
|
+
"""
|
|
258
|
+
text = project_ref(ref)
|
|
259
|
+
if text.isdigit():
|
|
260
|
+
mod = _Mod.model_validate(self._get(f"/v1/mods/{text}")["data"])
|
|
261
|
+
return mod.to_project(self.name, project_type)
|
|
262
|
+
data = self._search(project_type, slug=text, pageSize=1)
|
|
263
|
+
if not data["data"]:
|
|
264
|
+
raise PlatformError(f"No CurseForge {project_type} found for {ref}")
|
|
265
|
+
return _Mod.model_validate(data["data"][0]).to_project(self.name, project_type)
|
|
266
|
+
|
|
267
|
+
def iter_releases(
|
|
268
|
+
self,
|
|
269
|
+
project: Project,
|
|
270
|
+
*,
|
|
271
|
+
game_version: str | None = None,
|
|
272
|
+
loader: str | None = None,
|
|
273
|
+
release_only: bool = False,
|
|
274
|
+
) -> Iterator[Release]:
|
|
275
|
+
"""Yield a project's releases, newest first, reading pages as needed.
|
|
276
|
+
|
|
277
|
+
Server packs are left out. CurseForge serves at most 10,000 files per
|
|
278
|
+
project, so older releases of a larger project are not listed.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
project: The project.
|
|
282
|
+
game_version: Only list this Minecraft version.
|
|
283
|
+
loader: Only list this mod loader.
|
|
284
|
+
release_only: Leave out alpha and beta releases.
|
|
285
|
+
|
|
286
|
+
Yields:
|
|
287
|
+
Each release, once.
|
|
288
|
+
|
|
289
|
+
Raises:
|
|
290
|
+
NetworkError: CurseForge could not be reached.
|
|
291
|
+
"""
|
|
292
|
+
seen: set[int] = set()
|
|
293
|
+
index = 0
|
|
294
|
+
while index + PAGE_SIZE <= MAX_RESULTS:
|
|
295
|
+
data = self._get(
|
|
296
|
+
f"/v1/mods/{project.id}/files",
|
|
297
|
+
gameVersion=game_version,
|
|
298
|
+
modLoaderType=LOADER_TYPES.get(loader or ""),
|
|
299
|
+
index=index,
|
|
300
|
+
pageSize=PAGE_SIZE,
|
|
301
|
+
)
|
|
302
|
+
page = [_File.model_validate(item) for item in data["data"]]
|
|
303
|
+
index += len(page)
|
|
304
|
+
for file in page:
|
|
305
|
+
unwanted = release_only and file.release_type != 1
|
|
306
|
+
if file.id not in seen and not file.is_server_pack and not unwanted:
|
|
307
|
+
seen.add(file.id)
|
|
308
|
+
yield file.to_release(self.name)
|
|
309
|
+
pagination: dict[str, Any] = data.get("pagination") or {}
|
|
310
|
+
if not page or index >= pagination.get("totalCount", 0):
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
def _files(self, file_ids: list[int]) -> dict[int, _File]:
|
|
314
|
+
"""Fetch files by id, in batches."""
|
|
315
|
+
files: dict[int, _File] = {}
|
|
316
|
+
for start in range(0, len(file_ids), BATCH_SIZE):
|
|
317
|
+
batch = file_ids[start : start + BATCH_SIZE]
|
|
318
|
+
data = self._post("/v1/mods/files", {"fileIds": batch})
|
|
319
|
+
for item in data["data"]:
|
|
320
|
+
file = _File.model_validate(item)
|
|
321
|
+
files[file.id] = file
|
|
322
|
+
return files
|
|
323
|
+
|
|
324
|
+
def _mods(self, mod_ids: list[int]) -> dict[int, _Mod]:
|
|
325
|
+
"""Fetch mods by id, in batches."""
|
|
326
|
+
mods: dict[int, _Mod] = {}
|
|
327
|
+
for start in range(0, len(mod_ids), BATCH_SIZE):
|
|
328
|
+
batch = mod_ids[start : start + BATCH_SIZE]
|
|
329
|
+
data = self._post("/v1/mods", {"modIds": batch, "filterPcOnly": False})
|
|
330
|
+
for item in data["data"]:
|
|
331
|
+
mod = _Mod.model_validate(item)
|
|
332
|
+
mods[mod.id] = mod
|
|
333
|
+
return mods
|
|
334
|
+
|
|
335
|
+
def _resolved_entry(
|
|
336
|
+
self, entry: PackEntry, file: _File, mod: _Mod | None
|
|
337
|
+
) -> PackEntry:
|
|
338
|
+
"""Fill in an entry from its file and mod."""
|
|
339
|
+
# CurseForge can omit the flag, and only an explicit False forbids downloads.
|
|
340
|
+
allowed = mod is None or mod.allow_mod_distribution is not False
|
|
341
|
+
url = file.download_url if allowed else None
|
|
342
|
+
manual_url = None
|
|
343
|
+
if url is None:
|
|
344
|
+
website = mod.links.website_url if mod and mod.links else None
|
|
345
|
+
base = website or f"https://www.curseforge.com/projects/{file.mod_id}"
|
|
346
|
+
manual_url = f"{base.rstrip('/')}/files/{file.id}"
|
|
347
|
+
folder = CLASS_FOLDERS.get(mod.class_id or 6 if mod else 6, "mods")
|
|
348
|
+
return dataclasses.replace(
|
|
349
|
+
entry,
|
|
350
|
+
path=f"{folder}/{file.file_name}",
|
|
351
|
+
urls=(url,) if url else (),
|
|
352
|
+
hashes=file.hash_map,
|
|
353
|
+
size=file.file_length,
|
|
354
|
+
manual_url=manual_url,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def resolve(self, pack: Modpack) -> Modpack:
|
|
358
|
+
"""Look up download details for a CurseForge pack.
|
|
359
|
+
|
|
360
|
+
A file whose author disallows third-party downloads stays unresolved and
|
|
361
|
+
carries a ``manual_url``. A file CurseForge no longer lists also stays
|
|
362
|
+
unresolved, with no ``manual_url``.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
pack: The modpack.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
The modpack with resolved entries.
|
|
369
|
+
|
|
370
|
+
Raises:
|
|
371
|
+
NetworkError: CurseForge could not be reached.
|
|
372
|
+
"""
|
|
373
|
+
pending = [
|
|
374
|
+
entry
|
|
375
|
+
for entry in pack.entries
|
|
376
|
+
if entry.ref and entry.ref.platform == self.name and not entry.is_resolved
|
|
377
|
+
]
|
|
378
|
+
ids = [int(entry.ref.file_id) for entry in pending if entry.ref]
|
|
379
|
+
files = self._files(ids)
|
|
380
|
+
mods = self._mods(sorted({file.mod_id for file in files.values()}))
|
|
381
|
+
|
|
382
|
+
def resolved(entry: PackEntry) -> PackEntry:
|
|
383
|
+
if entry.ref is None or entry.ref.platform != self.name:
|
|
384
|
+
return entry
|
|
385
|
+
file = files.get(int(entry.ref.file_id))
|
|
386
|
+
if file is None:
|
|
387
|
+
return entry
|
|
388
|
+
return self._resolved_entry(entry, file, mods.get(file.mod_id))
|
|
389
|
+
|
|
390
|
+
return pack.with_entries(tuple(resolved(entry) for entry in pack.entries))
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""The Modrinth platform."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from typing import Any, ClassVar, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from ..net import Session
|
|
10
|
+
from ..net._session import DEFAULT_USER_AGENT
|
|
11
|
+
from ._base import (
|
|
12
|
+
Platform,
|
|
13
|
+
Project,
|
|
14
|
+
ProjectType,
|
|
15
|
+
Release,
|
|
16
|
+
ReleaseFile,
|
|
17
|
+
SearchResults,
|
|
18
|
+
project_ref,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
API_URL = "https://api.modrinth.com/v2"
|
|
22
|
+
SITE_URL = "https://modrinth.com"
|
|
23
|
+
PAGE_SIZE = 100
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Hit(BaseModel):
|
|
27
|
+
"""One search result from the Modrinth API."""
|
|
28
|
+
|
|
29
|
+
project_id: str
|
|
30
|
+
slug: str
|
|
31
|
+
title: str
|
|
32
|
+
description: str = ""
|
|
33
|
+
project_type: str = "modpack"
|
|
34
|
+
icon_url: str | None = None
|
|
35
|
+
downloads: int = 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _SearchResponse(BaseModel):
|
|
39
|
+
"""The Modrinth API's search response."""
|
|
40
|
+
|
|
41
|
+
hits: list[_Hit]
|
|
42
|
+
total_hits: int = 0
|
|
43
|
+
offset: int = 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Project(BaseModel):
|
|
47
|
+
"""A project from the Modrinth API."""
|
|
48
|
+
|
|
49
|
+
id: str
|
|
50
|
+
slug: str
|
|
51
|
+
title: str
|
|
52
|
+
description: str = ""
|
|
53
|
+
project_type: str = "modpack"
|
|
54
|
+
icon_url: str | None = None
|
|
55
|
+
downloads: int = 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _File(BaseModel):
|
|
59
|
+
"""A version's file from the Modrinth API."""
|
|
60
|
+
|
|
61
|
+
filename: str
|
|
62
|
+
url: str
|
|
63
|
+
hashes: dict[str, str] = Field(default_factory=dict[str, str])
|
|
64
|
+
size: int | None = None
|
|
65
|
+
primary: bool = False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _Version(BaseModel):
|
|
69
|
+
"""A version from the Modrinth API."""
|
|
70
|
+
|
|
71
|
+
id: str
|
|
72
|
+
project_id: str
|
|
73
|
+
name: str
|
|
74
|
+
version_type: Literal["release", "beta", "alpha"] = "release"
|
|
75
|
+
date_published: str = ""
|
|
76
|
+
game_versions: list[str] = Field(default_factory=list[str])
|
|
77
|
+
loaders: list[str] = Field(default_factory=list[str])
|
|
78
|
+
files: list[_File] = Field(default_factory=list[_File])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _project_url(project_type: str, slug: str) -> str:
|
|
82
|
+
"""Return a project's web page."""
|
|
83
|
+
return f"{SITE_URL}/{project_type}/{slug}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Modrinth(Platform):
|
|
87
|
+
"""Modrinth. No API key is needed.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
user_agent: Identifies your tool. Modrinth asks every client to set one.
|
|
91
|
+
session: The session to use, instead of creating one. ``user_agent`` is
|
|
92
|
+
then ignored.
|
|
93
|
+
base_url: The API's base URL.
|
|
94
|
+
|
|
95
|
+
Attributes:
|
|
96
|
+
base_url: The API's base URL.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
name: ClassVar[str] = "modrinth"
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
user_agent: str | None = None,
|
|
104
|
+
*,
|
|
105
|
+
session: Session | None = None,
|
|
106
|
+
base_url: str = API_URL,
|
|
107
|
+
) -> None:
|
|
108
|
+
owned = None if session else Session(user_agent or DEFAULT_USER_AGENT)
|
|
109
|
+
super().__init__(session or owned)
|
|
110
|
+
self._owns_session = session is None
|
|
111
|
+
self.base_url = base_url.rstrip("/")
|
|
112
|
+
|
|
113
|
+
def _get(self, path: str, **params: Any) -> Any:
|
|
114
|
+
"""Send a GET request to the API."""
|
|
115
|
+
return self.session.get_json(self.base_url + path, params=params)
|
|
116
|
+
|
|
117
|
+
def search(
|
|
118
|
+
self,
|
|
119
|
+
query: str,
|
|
120
|
+
*,
|
|
121
|
+
project_type: ProjectType = "modpack",
|
|
122
|
+
game_version: str | None = None,
|
|
123
|
+
loader: str | None = None,
|
|
124
|
+
offset: int = 0,
|
|
125
|
+
limit: int = 20,
|
|
126
|
+
) -> SearchResults:
|
|
127
|
+
"""Search Modrinth's catalogue.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
query: The search text.
|
|
131
|
+
project_type: The kind of project to find.
|
|
132
|
+
game_version: Only match this Minecraft version.
|
|
133
|
+
loader: Only match this mod loader.
|
|
134
|
+
offset: How many results to skip.
|
|
135
|
+
limit: The maximum number of results to return.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
One page of results.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
NetworkError: Modrinth could not be reached.
|
|
142
|
+
"""
|
|
143
|
+
facets = [[f"project_type:{project_type}"]]
|
|
144
|
+
if game_version:
|
|
145
|
+
facets.append([f"versions:{game_version}"])
|
|
146
|
+
if loader:
|
|
147
|
+
facets.append([f"categories:{loader}"])
|
|
148
|
+
data = _SearchResponse.model_validate(
|
|
149
|
+
self._get(
|
|
150
|
+
"/search",
|
|
151
|
+
query=query,
|
|
152
|
+
facets=json.dumps(facets),
|
|
153
|
+
offset=offset,
|
|
154
|
+
limit=limit,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
projects = tuple(
|
|
158
|
+
Project(
|
|
159
|
+
platform=self.name,
|
|
160
|
+
id=hit.project_id,
|
|
161
|
+
slug=hit.slug,
|
|
162
|
+
name=hit.title,
|
|
163
|
+
summary=hit.description,
|
|
164
|
+
project_type=hit.project_type,
|
|
165
|
+
icon_url=hit.icon_url,
|
|
166
|
+
url=_project_url(hit.project_type, hit.slug),
|
|
167
|
+
downloads=hit.downloads,
|
|
168
|
+
)
|
|
169
|
+
for hit in data.hits
|
|
170
|
+
)
|
|
171
|
+
return SearchResults(projects, data.total_hits, data.offset)
|
|
172
|
+
|
|
173
|
+
def get_project(
|
|
174
|
+
self, ref: str | int, *, project_type: ProjectType = "modpack"
|
|
175
|
+
) -> Project:
|
|
176
|
+
"""Look up a project by id, slug or URL.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
ref: The project's id, slug or web address.
|
|
180
|
+
project_type: Not used, because Modrinth's ids are unique across types.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
The project.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
NetworkError: Modrinth could not be reached or does not know the project.
|
|
187
|
+
"""
|
|
188
|
+
data = _Project.model_validate(self._get(f"/project/{project_ref(ref)}"))
|
|
189
|
+
return Project(
|
|
190
|
+
platform=self.name,
|
|
191
|
+
id=data.id,
|
|
192
|
+
slug=data.slug,
|
|
193
|
+
name=data.title,
|
|
194
|
+
summary=data.description,
|
|
195
|
+
project_type=data.project_type,
|
|
196
|
+
icon_url=data.icon_url,
|
|
197
|
+
url=_project_url(data.project_type, data.slug),
|
|
198
|
+
downloads=data.downloads,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def iter_releases(
|
|
202
|
+
self,
|
|
203
|
+
project: Project,
|
|
204
|
+
*,
|
|
205
|
+
game_version: str | None = None,
|
|
206
|
+
loader: str | None = None,
|
|
207
|
+
release_only: bool = False,
|
|
208
|
+
) -> Iterator[Release]:
|
|
209
|
+
"""Yield a project's releases, newest first, reading pages as needed.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
project: The project.
|
|
213
|
+
game_version: Only list this Minecraft version.
|
|
214
|
+
loader: Only list this mod loader.
|
|
215
|
+
release_only: Leave out alpha and beta releases.
|
|
216
|
+
|
|
217
|
+
Yields:
|
|
218
|
+
Each release, once.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
NetworkError: Modrinth could not be reached.
|
|
222
|
+
"""
|
|
223
|
+
params: dict[str, Any] = {"include_changelog": "false", "limit": PAGE_SIZE}
|
|
224
|
+
if game_version:
|
|
225
|
+
params["game_versions"] = json.dumps([game_version])
|
|
226
|
+
if loader:
|
|
227
|
+
params["loaders"] = json.dumps([loader])
|
|
228
|
+
seen: set[str] = set()
|
|
229
|
+
offset = 0
|
|
230
|
+
while True:
|
|
231
|
+
page = self._get(f"/project/{project.id}/version", offset=offset, **params)
|
|
232
|
+
# Modrinth can return short pages before the end of the list, so
|
|
233
|
+
# only an empty page ends it, and the offset always moves by a page.
|
|
234
|
+
if not page:
|
|
235
|
+
return
|
|
236
|
+
offset += PAGE_SIZE
|
|
237
|
+
for item in page:
|
|
238
|
+
version = _Version.model_validate(item)
|
|
239
|
+
unwanted = release_only and version.version_type != "release"
|
|
240
|
+
if version.id not in seen and not unwanted:
|
|
241
|
+
seen.add(version.id)
|
|
242
|
+
yield self._to_release(version)
|
|
243
|
+
|
|
244
|
+
def _to_release(self, version: _Version) -> Release:
|
|
245
|
+
"""Convert a Modrinth version to a `Release`."""
|
|
246
|
+
return Release(
|
|
247
|
+
platform=self.name,
|
|
248
|
+
id=version.id,
|
|
249
|
+
project_id=version.project_id,
|
|
250
|
+
name=version.name,
|
|
251
|
+
release_type=version.version_type,
|
|
252
|
+
published=version.date_published,
|
|
253
|
+
game_versions=tuple(version.game_versions),
|
|
254
|
+
loaders=tuple(version.loaders),
|
|
255
|
+
files=tuple(
|
|
256
|
+
ReleaseFile(
|
|
257
|
+
file.filename, (file.url,), file.hashes, file.size, file.primary
|
|
258
|
+
)
|
|
259
|
+
for file in version.files
|
|
260
|
+
),
|
|
261
|
+
)
|