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