modrinth4py 0.0.1__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.
- modrinth4py/__init__.py +39 -0
- modrinth4py/client.py +311 -0
- modrinth4py/exceptions.py +15 -0
- modrinth4py/types.py +159 -0
- modrinth4py-0.0.1.dist-info/METADATA +115 -0
- modrinth4py-0.0.1.dist-info/RECORD +9 -0
- modrinth4py-0.0.1.dist-info/WHEEL +5 -0
- modrinth4py-0.0.1.dist-info/licenses/LICENSE +21 -0
- modrinth4py-0.0.1.dist-info/top_level.txt +1 -0
modrinth4py/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from modrinth4py.client import ModrinthClient
|
|
2
|
+
from modrinth4py.exceptions import ModrinthAPIError
|
|
3
|
+
from modrinth4py.types import (
|
|
4
|
+
DictKV,
|
|
5
|
+
ListDictKV,
|
|
6
|
+
SideSupport,
|
|
7
|
+
ProjectStatus,
|
|
8
|
+
RequestedStatus,
|
|
9
|
+
ProjectType,
|
|
10
|
+
VersionType,
|
|
11
|
+
VersionStatus,
|
|
12
|
+
RequestedVersionStatus,
|
|
13
|
+
DonationUrl,
|
|
14
|
+
NewVersion,
|
|
15
|
+
NewProject,
|
|
16
|
+
ProjectUpdate,
|
|
17
|
+
VersionUpdate,
|
|
18
|
+
GalleryImage,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ModrinthClient",
|
|
23
|
+
"ModrinthAPIError",
|
|
24
|
+
"DictKV",
|
|
25
|
+
"ListDictKV",
|
|
26
|
+
"SideSupport",
|
|
27
|
+
"ProjectStatus",
|
|
28
|
+
"RequestedStatus",
|
|
29
|
+
"ProjectType",
|
|
30
|
+
"VersionType",
|
|
31
|
+
"VersionStatus",
|
|
32
|
+
"RequestedVersionStatus",
|
|
33
|
+
"DonationUrl",
|
|
34
|
+
"NewVersion",
|
|
35
|
+
"NewProject",
|
|
36
|
+
"ProjectUpdate",
|
|
37
|
+
"VersionUpdate",
|
|
38
|
+
"GalleryImage",
|
|
39
|
+
]
|
modrinth4py/client.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from contextlib import ExitStack
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, List, Any, IO, Iterator, Callable, Dict
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
from requests.adapters import HTTPAdapter
|
|
14
|
+
from urllib3 import Retry
|
|
15
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
16
|
+
|
|
17
|
+
from modrinth4py.exceptions import ModrinthAPIError
|
|
18
|
+
from modrinth4py.types import (
|
|
19
|
+
DictKV,
|
|
20
|
+
ListDictKV,
|
|
21
|
+
NewProject,
|
|
22
|
+
NewVersion,
|
|
23
|
+
GalleryImage,
|
|
24
|
+
ProjectUpdate,
|
|
25
|
+
VersionUpdate,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
__version__ = version("modrinth4py")
|
|
32
|
+
except PackageNotFoundError:
|
|
33
|
+
__version__ = "unknown"
|
|
34
|
+
|
|
35
|
+
def _cut_game_versions_until(cutoff_version: str, versions: List[DictKV]) -> List[DictKV]:
|
|
36
|
+
result: List[DictKV] = []
|
|
37
|
+
for version in versions:
|
|
38
|
+
result.append(version)
|
|
39
|
+
if version.get("version") == cutoff_version:
|
|
40
|
+
break
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ModrinthClient:
|
|
45
|
+
"""
|
|
46
|
+
HTTP client for the Modrinth API.
|
|
47
|
+
|
|
48
|
+
Rate limiting is handled automatically. Requests are retried on 429/5xx responses.
|
|
49
|
+
For concurrent workloads, use :meth:`parallel_requests`.
|
|
50
|
+
|
|
51
|
+
:param token: Modrinth API token (PAT).
|
|
52
|
+
:param api_url: Base URL for the API. Defaults to ``https://api.modrinth.com``.
|
|
53
|
+
:param user_agent: Value sent as the ``User-Agent`` header.
|
|
54
|
+
:param debug: Enable debug-level request/response logging via stdlib ``logging``.
|
|
55
|
+
:param verbose_debug: Also log full request bodies (implies ``debug=True``).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
DEFAULT_API_URL = "https://api.modrinth.com"
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
token: str,
|
|
63
|
+
api_url: str = DEFAULT_API_URL,
|
|
64
|
+
user_agent: str = f"modrinth4py/{__version__}",
|
|
65
|
+
debug: bool = False,
|
|
66
|
+
verbose_debug: bool = False,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.api_url = api_url
|
|
69
|
+
self._debug = debug or verbose_debug
|
|
70
|
+
self._verbose_debug = verbose_debug
|
|
71
|
+
|
|
72
|
+
self._session = requests.Session()
|
|
73
|
+
self._session.headers.update({"Authorization": token, "User-Agent": user_agent})
|
|
74
|
+
|
|
75
|
+
self._ratelimit_lock = threading.Lock()
|
|
76
|
+
self._ratelimit_limit = 300
|
|
77
|
+
self._ratelimit_remaining = 300
|
|
78
|
+
self._ratelimit_reset = 0
|
|
79
|
+
|
|
80
|
+
def _make_thread_session(self) -> requests.Session:
|
|
81
|
+
s = requests.Session()
|
|
82
|
+
s.headers.update(self._session.headers)
|
|
83
|
+
s.headers["Connection"] = "close"
|
|
84
|
+
|
|
85
|
+
retry = Retry(
|
|
86
|
+
total=10,
|
|
87
|
+
backoff_factor=0.3,
|
|
88
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
89
|
+
)
|
|
90
|
+
adapter = HTTPAdapter(max_retries=retry, pool_connections=1, pool_maxsize=1)
|
|
91
|
+
s.mount("https://", adapter)
|
|
92
|
+
return s
|
|
93
|
+
|
|
94
|
+
def _update_ratelimit(self, response: requests.Response) -> None:
|
|
95
|
+
with self._ratelimit_lock:
|
|
96
|
+
try:
|
|
97
|
+
self._ratelimit_limit = int(response.headers.get("X-Ratelimit-Limit", self._ratelimit_limit))
|
|
98
|
+
self._ratelimit_remaining = int(response.headers.get("X-Ratelimit-Remaining", self._ratelimit_remaining))
|
|
99
|
+
self._ratelimit_reset = int(response.headers.get("X-Ratelimit-Reset", 0))
|
|
100
|
+
except (ValueError, TypeError):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
if self._debug:
|
|
104
|
+
logger.debug(
|
|
105
|
+
"rate limit updated: limit=%d remaining=%d reset=%d",
|
|
106
|
+
self._ratelimit_limit,
|
|
107
|
+
self._ratelimit_remaining,
|
|
108
|
+
self._ratelimit_reset,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _respect_ratelimit(self) -> None:
|
|
112
|
+
with self._ratelimit_lock:
|
|
113
|
+
if self._ratelimit_remaining > 0:
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
sleep_time = self._ratelimit_reset
|
|
117
|
+
logger.warning("rate limited - sleeping %d seconds", sleep_time)
|
|
118
|
+
self._ratelimit_remaining = self._ratelimit_limit
|
|
119
|
+
self._ratelimit_reset = 0
|
|
120
|
+
|
|
121
|
+
if sleep_time > 0:
|
|
122
|
+
time.sleep(sleep_time)
|
|
123
|
+
|
|
124
|
+
def _log_request(self, prepped: requests.PreparedRequest) -> None:
|
|
125
|
+
logger.debug("%s %s", prepped.method, prepped.url)
|
|
126
|
+
for k, v in prepped.headers.items():
|
|
127
|
+
if k.lower() == "authorization":
|
|
128
|
+
v = f"{v[:10]}..." if len(v) > 10 else "***"
|
|
129
|
+
logger.debug(" %s: %s", k, v)
|
|
130
|
+
|
|
131
|
+
if prepped.body is None:
|
|
132
|
+
logger.debug(" body: (none)")
|
|
133
|
+
elif "json" in prepped.headers.get("Content-Type", "").lower() and isinstance(prepped.body, bytes):
|
|
134
|
+
try:
|
|
135
|
+
logger.debug(" body: %s", json.dumps(json.loads(prepped.body.decode()), indent=2))
|
|
136
|
+
except Exception:
|
|
137
|
+
logger.debug(" body (raw): %r", prepped.body)
|
|
138
|
+
elif isinstance(prepped.body, (str, bytes)):
|
|
139
|
+
preview = prepped.body[:200] if isinstance(prepped.body, str) else prepped.body[:200].decode(errors="replace")
|
|
140
|
+
logger.debug(" body: %s", preview)
|
|
141
|
+
else:
|
|
142
|
+
logger.debug(" body: <binary/multipart>")
|
|
143
|
+
|
|
144
|
+
def _request(self, method: str, endpoint: str, api_version: int = 2, **kwargs) -> Any:
|
|
145
|
+
url = f"{self.api_url}/v{api_version}{endpoint}"
|
|
146
|
+
|
|
147
|
+
thread = threading.current_thread()
|
|
148
|
+
session: Optional[requests.Session] = getattr(thread, "_modrinth4py_session", None)
|
|
149
|
+
if session is None:
|
|
150
|
+
session = self._make_thread_session()
|
|
151
|
+
setattr(thread, "_modrinth4py_session", session)
|
|
152
|
+
|
|
153
|
+
self._respect_ratelimit()
|
|
154
|
+
|
|
155
|
+
response: Optional[requests.Response] = None
|
|
156
|
+
try:
|
|
157
|
+
prepped = session.prepare_request(requests.Request(method, url, **kwargs))
|
|
158
|
+
|
|
159
|
+
if self._verbose_debug:
|
|
160
|
+
self._log_request(prepped)
|
|
161
|
+
elif self._debug:
|
|
162
|
+
logger.debug("%s %s", method, url)
|
|
163
|
+
|
|
164
|
+
settings = session.merge_environment_settings(prepped.url, {}, None, None, None)
|
|
165
|
+
response = session.send(prepped, **settings)
|
|
166
|
+
|
|
167
|
+
if self._debug:
|
|
168
|
+
logger.debug("response: status=%d length=%d", response.status_code, len(response.content))
|
|
169
|
+
|
|
170
|
+
self._update_ratelimit(response)
|
|
171
|
+
response.raise_for_status()
|
|
172
|
+
return response.json() if response.text else {}
|
|
173
|
+
|
|
174
|
+
except requests.HTTPError as exc:
|
|
175
|
+
if response is not None:
|
|
176
|
+
logger.error("HTTP %d: %s", response.status_code, response.text[:500])
|
|
177
|
+
try:
|
|
178
|
+
error_body = response.json() if response is not None else {}
|
|
179
|
+
except Exception:
|
|
180
|
+
error_body = {"error": response.text if response else "no response"}
|
|
181
|
+
|
|
182
|
+
raise ModrinthAPIError(
|
|
183
|
+
str(exc),
|
|
184
|
+
status_code=response.status_code if response is not None else None,
|
|
185
|
+
response=error_body,
|
|
186
|
+
) from exc
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def _to_dict(obj: Any) -> DictKV:
|
|
190
|
+
return {k: v for k, v in obj.__dict__.items() if v is not None}
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def _open_files(paths: List[Path]) -> Iterator[Dict[str, IO[bytes]]]:
|
|
194
|
+
with ExitStack() as stack:
|
|
195
|
+
yield {f"file{i}": stack.enter_context(p.open("rb")) for i, p in enumerate(paths)}
|
|
196
|
+
|
|
197
|
+
def get_project(self, id_or_slug: str) -> DictKV:
|
|
198
|
+
"""Fetch a project by its ID or slug."""
|
|
199
|
+
return self._request("GET", f"/project/{id_or_slug}")
|
|
200
|
+
|
|
201
|
+
def create_project(self, project: NewProject, icon_path: Optional[Path] = None) -> DictKV:
|
|
202
|
+
"""Create a new project. Optionally attach an icon."""
|
|
203
|
+
payload = self._to_dict(project)
|
|
204
|
+
if "donation_urls" in payload:
|
|
205
|
+
payload["donation_urls"] = [du.__dict__ for du in payload["donation_urls"]]
|
|
206
|
+
|
|
207
|
+
if icon_path:
|
|
208
|
+
with icon_path.open("rb") as icon_file:
|
|
209
|
+
return self._request("POST", "/project", data={"data": json.dumps(payload)}, files={"icon": icon_file})
|
|
210
|
+
return self._request("POST", "/project", data={"data": json.dumps(payload)})
|
|
211
|
+
|
|
212
|
+
def modify_project(self, id_or_slug: str, update: ProjectUpdate) -> None:
|
|
213
|
+
"""Partially update a project."""
|
|
214
|
+
self._request("PATCH", f"/project/{id_or_slug}", json=self._to_dict(update))
|
|
215
|
+
|
|
216
|
+
def change_project_icon(self, id_or_slug: str, icon_path: Path, ext: str) -> None:
|
|
217
|
+
"""Replace the icon of a project."""
|
|
218
|
+
with icon_path.open("rb") as icon_file:
|
|
219
|
+
self._request("PATCH", f"/project/{id_or_slug}/icon", params={"ext": ext}, data=icon_file)
|
|
220
|
+
|
|
221
|
+
def get_project_versions(self, id_or_slug: str) -> ListDictKV:
|
|
222
|
+
"""List all versions of a project."""
|
|
223
|
+
return self._request("GET", f"/project/{id_or_slug}/version")
|
|
224
|
+
|
|
225
|
+
def get_version(self, version_id: str) -> DictKV:
|
|
226
|
+
"""Fetch a single version by its ID."""
|
|
227
|
+
return self._request("GET", f"/version/{version_id}")
|
|
228
|
+
|
|
229
|
+
def create_version(
|
|
230
|
+
self,
|
|
231
|
+
version: NewVersion,
|
|
232
|
+
file_paths: List[Path],
|
|
233
|
+
primary_file: Optional[str] = None,
|
|
234
|
+
) -> DictKV:
|
|
235
|
+
"""Upload a new version with one or more files."""
|
|
236
|
+
with ExitStack() as stack:
|
|
237
|
+
files = {f"file{i}": stack.enter_context(p.open("rb")) for i, p in enumerate(file_paths)}
|
|
238
|
+
payload: DictKV = {**version.__dict__, "file_parts": list(files.keys())}
|
|
239
|
+
if primary_file:
|
|
240
|
+
payload["primary_file"] = primary_file
|
|
241
|
+
return self._request("POST", "/version", data={"data": json.dumps(payload)}, files=files)
|
|
242
|
+
|
|
243
|
+
def modify_version(self, version_id: str, update: VersionUpdate) -> None:
|
|
244
|
+
"""Partially update a version."""
|
|
245
|
+
self._request("PATCH", f"/version/{version_id}", json=self._to_dict(update))
|
|
246
|
+
|
|
247
|
+
def delete_version(self, version_id: str) -> None:
|
|
248
|
+
"""Delete a version permanently."""
|
|
249
|
+
self._request("DELETE", f"/version/{version_id}")
|
|
250
|
+
|
|
251
|
+
def add_gallery_image(self, id_or_slug: str, image: GalleryImage) -> None:
|
|
252
|
+
"""Upload an image to a project's gallery."""
|
|
253
|
+
params: DictKV = {"ext": image.ext, "featured": str(image.featured).lower()}
|
|
254
|
+
if image.title:
|
|
255
|
+
params["title"] = image.title
|
|
256
|
+
if image.description:
|
|
257
|
+
params["description"] = image.description
|
|
258
|
+
if image.ordering is not None:
|
|
259
|
+
params["ordering"] = image.ordering
|
|
260
|
+
|
|
261
|
+
with image.image_path.open("rb") as img_file:
|
|
262
|
+
self._request("POST", f"/project/{id_or_slug}/gallery", params=params, data=img_file)
|
|
263
|
+
|
|
264
|
+
def delete_gallery_image(self, id_or_slug: str, image_url: str) -> None:
|
|
265
|
+
"""Remove an image from a project's gallery by its URL."""
|
|
266
|
+
self._request("DELETE", f"/project/{id_or_slug}/gallery", params={"url": image_url})
|
|
267
|
+
|
|
268
|
+
def get_organization_projects(self, organization_id: str) -> ListDictKV:
|
|
269
|
+
"""List all projects belonging to an organization."""
|
|
270
|
+
return self._request("GET", f"/organization/{organization_id}/projects", api_version=3)
|
|
271
|
+
|
|
272
|
+
def get_game_versions(self) -> ListDictKV:
|
|
273
|
+
"""Return all known game versions from the Modrinth tag API."""
|
|
274
|
+
return self._request("GET", "/tag/game_version")
|
|
275
|
+
|
|
276
|
+
def get_game_versions_until(self, cutoff_version: str) -> ListDictKV:
|
|
277
|
+
"""Return game versions from newest down to and including ``cutoff_version``."""
|
|
278
|
+
return _cut_game_versions_until(cutoff_version, self.get_game_versions())
|
|
279
|
+
|
|
280
|
+
def get_loaders(self) -> ListDictKV:
|
|
281
|
+
"""Return all known mod loaders from the Modrinth tag API."""
|
|
282
|
+
return self._request("GET", "/tag/loader")
|
|
283
|
+
|
|
284
|
+
@staticmethod
|
|
285
|
+
def parallel_requests(requests_list: List[Callable[[], Any]], max_parallel: int = 6) -> List[Any]:
|
|
286
|
+
"""
|
|
287
|
+
Execute a list of callables in parallel using a thread pool.
|
|
288
|
+
|
|
289
|
+
Each callable should be a zero-argument function that calls one of the
|
|
290
|
+
client methods (e.g. ``lambda: client.get_project("sodium")``).
|
|
291
|
+
Results are returned in the same order as the input list.
|
|
292
|
+
Raises the first exception encountered, if any.
|
|
293
|
+
"""
|
|
294
|
+
results: List[Any] = [None] * len(requests_list)
|
|
295
|
+
|
|
296
|
+
def _wrap(fn: Callable[[], Any]) -> Any:
|
|
297
|
+
try:
|
|
298
|
+
return fn()
|
|
299
|
+
except Exception as exc:
|
|
300
|
+
return exc
|
|
301
|
+
|
|
302
|
+
with ThreadPoolExecutor(max_workers=max_parallel) as executor:
|
|
303
|
+
future_to_index = {executor.submit(_wrap, fn): i for i, fn in enumerate(requests_list)}
|
|
304
|
+
for future in as_completed(future_to_index):
|
|
305
|
+
results[future_to_index[future]] = future.result()
|
|
306
|
+
|
|
307
|
+
for result in results:
|
|
308
|
+
if isinstance(result, Exception):
|
|
309
|
+
raise result
|
|
310
|
+
|
|
311
|
+
return results
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Dict, Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ModrinthAPIError(Exception):
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
status_code: Optional[int] = None,
|
|
11
|
+
response: Optional[Dict[str, Any]] = None,
|
|
12
|
+
) -> None:
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
self.response = response or {}
|
modrinth4py/types.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, List, Dict, Any
|
|
7
|
+
|
|
8
|
+
DictKV = Dict[str, Any]
|
|
9
|
+
ListDictKV = List[DictKV]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SideSupport(str, Enum):
|
|
13
|
+
REQUIRED = "required"
|
|
14
|
+
OPTIONAL = "optional"
|
|
15
|
+
UNSUPPORTED = "unsupported"
|
|
16
|
+
UNKNOWN = "unknown"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProjectStatus(str, Enum):
|
|
20
|
+
APPROVED = "approved"
|
|
21
|
+
ARCHIVED = "archived"
|
|
22
|
+
REJECTED = "rejected"
|
|
23
|
+
DRAFT = "draft"
|
|
24
|
+
UNLISTED = "unlisted"
|
|
25
|
+
PROCESSING = "processing"
|
|
26
|
+
WITHHELD = "withheld"
|
|
27
|
+
SCHEDULED = "scheduled"
|
|
28
|
+
PRIVATE = "private"
|
|
29
|
+
UNKNOWN = "unknown"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RequestedStatus(str, Enum):
|
|
33
|
+
APPROVED = "approved"
|
|
34
|
+
ARCHIVED = "archived"
|
|
35
|
+
UNLISTED = "unlisted"
|
|
36
|
+
PRIVATE = "private"
|
|
37
|
+
DRAFT = "draft"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProjectType(str, Enum):
|
|
41
|
+
MOD = "mod"
|
|
42
|
+
MODPACK = "modpack"
|
|
43
|
+
RESOURCEPACK = "resourcepack"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class VersionType(str, Enum):
|
|
47
|
+
RELEASE = "release"
|
|
48
|
+
BETA = "beta"
|
|
49
|
+
ALPHA = "alpha"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class VersionStatus(str, Enum):
|
|
53
|
+
LISTED = "listed"
|
|
54
|
+
ARCHIVED = "archived"
|
|
55
|
+
DRAFT = "draft"
|
|
56
|
+
UNLISTED = "unlisted"
|
|
57
|
+
SCHEDULED = "scheduled"
|
|
58
|
+
UNKNOWN = "unknown"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RequestedVersionStatus(str, Enum):
|
|
62
|
+
LISTED = "listed"
|
|
63
|
+
ARCHIVED = "archived"
|
|
64
|
+
DRAFT = "draft"
|
|
65
|
+
UNLISTED = "unlisted"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class DonationUrl:
|
|
70
|
+
id: str
|
|
71
|
+
platform: str
|
|
72
|
+
url: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class NewVersion:
|
|
77
|
+
name: str
|
|
78
|
+
version_number: str
|
|
79
|
+
project_id: str
|
|
80
|
+
game_versions: List[str]
|
|
81
|
+
loaders: List[str]
|
|
82
|
+
version_type: VersionType
|
|
83
|
+
featured: bool = True
|
|
84
|
+
changelog: Optional[str] = None
|
|
85
|
+
dependencies: Optional[List[DictKV]] = None
|
|
86
|
+
status: VersionStatus = VersionStatus.LISTED
|
|
87
|
+
requested_status: Optional[RequestedVersionStatus] = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class NewProject:
|
|
92
|
+
slug: str
|
|
93
|
+
title: str
|
|
94
|
+
description: str
|
|
95
|
+
categories: List[str]
|
|
96
|
+
client_side: SideSupport
|
|
97
|
+
server_side: SideSupport
|
|
98
|
+
body: str
|
|
99
|
+
project_type: ProjectType
|
|
100
|
+
initial_versions: Optional[List[NewVersion]] = field(default_factory=list)
|
|
101
|
+
additional_categories: Optional[List[str]] = None
|
|
102
|
+
issues_url: Optional[str] = None
|
|
103
|
+
source_url: Optional[str] = None
|
|
104
|
+
organization_id: Optional[str] = None
|
|
105
|
+
wiki_url: Optional[str] = None
|
|
106
|
+
discord_url: Optional[str] = None
|
|
107
|
+
donation_urls: Optional[List[DonationUrl]] = None
|
|
108
|
+
requested_status: Optional[RequestedStatus] = RequestedStatus.UNLISTED
|
|
109
|
+
status: Optional[RequestedStatus] = RequestedStatus.DRAFT
|
|
110
|
+
license_id: Optional[str] = None
|
|
111
|
+
license_url: Optional[str] = None
|
|
112
|
+
is_draft: bool = True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class ProjectUpdate:
|
|
117
|
+
slug: Optional[str] = None
|
|
118
|
+
title: Optional[str] = None
|
|
119
|
+
description: Optional[str] = None
|
|
120
|
+
categories: Optional[List[str]] = None
|
|
121
|
+
client_side: Optional[str] = None
|
|
122
|
+
server_side: Optional[str] = None
|
|
123
|
+
body: Optional[str] = None
|
|
124
|
+
status: Optional[str] = None
|
|
125
|
+
requested_status: Optional[str] = None
|
|
126
|
+
additional_categories: Optional[List[str]] = None
|
|
127
|
+
issues_url: Optional[str] = None
|
|
128
|
+
source_url: Optional[str] = None
|
|
129
|
+
wiki_url: Optional[str] = None
|
|
130
|
+
discord_url: Optional[str] = None
|
|
131
|
+
donation_urls: Optional[List[Dict[str, Any]]] = None
|
|
132
|
+
license_id: Optional[str] = None
|
|
133
|
+
license_url: Optional[str] = None
|
|
134
|
+
moderation_message: Optional[str] = None
|
|
135
|
+
moderation_message_body: Optional[str] = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class VersionUpdate:
|
|
140
|
+
name: Optional[str] = None
|
|
141
|
+
version_number: Optional[str] = None
|
|
142
|
+
changelog: Optional[str] = None
|
|
143
|
+
dependencies: Optional[List[DictKV]] = None
|
|
144
|
+
game_versions: Optional[List[str]] = None
|
|
145
|
+
version_type: Optional[VersionType] = None
|
|
146
|
+
loaders: Optional[List[str]] = None
|
|
147
|
+
featured: Optional[bool] = None
|
|
148
|
+
status: Optional[VersionStatus] = None
|
|
149
|
+
requested_status: Optional[RequestedVersionStatus] = None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class GalleryImage:
|
|
154
|
+
image_path: Path
|
|
155
|
+
ext: str
|
|
156
|
+
featured: bool
|
|
157
|
+
title: Optional[str] = None
|
|
158
|
+
description: Optional[str] = None
|
|
159
|
+
ordering: Optional[int] = None
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: modrinth4py
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A Python API client for the Modrinth API
|
|
5
|
+
Author-email: rotgruengelb <daniel+modrinth4py@rotgruengelb.net>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/rotgruengelb/modrinth4py
|
|
8
|
+
Project-URL: Documentation, https://github.com/rotgruengelb/modrinth4py#readme
|
|
9
|
+
Project-URL: Source, https://github.com/rotgruengelb/modrinth4py
|
|
10
|
+
Project-URL: Tracker, https://github.com/rotgruengelb/modrinth4py/issues
|
|
11
|
+
Keywords: modrinth,api,client,minecraft,mods
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Development Status :: 4 - Beta
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: requests>=2.32.5
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# modrinth4py
|
|
24
|
+
|
|
25
|
+
A Python API client for the [Modrinth](https://modrinth.com) API.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install modrinth4py
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from modrinth4py import ModrinthClient, NewProject, SideSupport, ProjectType
|
|
37
|
+
|
|
38
|
+
client = ModrinthClient(token="your-modrinth-pat")
|
|
39
|
+
|
|
40
|
+
project = client.get_project("sodium")
|
|
41
|
+
print(project["title"])
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Creating a project
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from modrinth4py import NewProject, SideSupport, ProjectType, RequestedStatus
|
|
48
|
+
|
|
49
|
+
new_project = NewProject(
|
|
50
|
+
slug="my-mod",
|
|
51
|
+
title="My Mod",
|
|
52
|
+
description="A short description",
|
|
53
|
+
categories=["utility"],
|
|
54
|
+
client_side=SideSupport.REQUIRED,
|
|
55
|
+
server_side=SideSupport.OPTIONAL,
|
|
56
|
+
body="## My Mod\nLong-form description here.",
|
|
57
|
+
project_type=ProjectType.MOD,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
result = client.create_project(new_project)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Uploading a version
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
from modrinth4py import NewVersion, VersionType
|
|
68
|
+
|
|
69
|
+
version = NewVersion(
|
|
70
|
+
name="1.0.0",
|
|
71
|
+
version_number="1.0.0",
|
|
72
|
+
project_id="your-id",
|
|
73
|
+
game_versions=["1.21"],
|
|
74
|
+
loaders=["fabric"],
|
|
75
|
+
version_type=VersionType.RELEASE,
|
|
76
|
+
changelog="Initial release.",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
client.create_version(version, file_paths=[Path("my-mod-1.0.0.jar")])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Modifying a project
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from modrinth4py import ModrinthClient, ProjectUpdate
|
|
86
|
+
|
|
87
|
+
client.modify_project("your-slug", ProjectUpdate(
|
|
88
|
+
discord_url="https://discord.gg/your-invite",
|
|
89
|
+
license_id="MIT",
|
|
90
|
+
))
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Parallel requests
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
projects = ModrinthClient.parallel_requests([
|
|
97
|
+
lambda: client.get_project("sodium"),
|
|
98
|
+
lambda: client.get_project("lithium"),
|
|
99
|
+
lambda: client.get_project("iris"),
|
|
100
|
+
])
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Logging
|
|
104
|
+
|
|
105
|
+
`modrinth4py` uses Python's standard `logging` module under the `modrinth4py.client` logger.
|
|
106
|
+
Pass `debug=True` to `ModrinthClient` to enable debug output, or configure your own handler:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
import logging
|
|
110
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
modrinth4py/__init__.py,sha256=Cp3MxwrWb4gmpeQ1qKI_ZZ4S1STEh6sFZ8LlmsrxBBc,764
|
|
2
|
+
modrinth4py/client.py,sha256=sGZgxWkDFssVput4FsQPI_j3nFjcnot3m0vmhekbAXY,12574
|
|
3
|
+
modrinth4py/exceptions.py,sha256=S_IFnnp6RFiovbq8K59UMIAF3mXsY8hQAI9-l1DfpVY,386
|
|
4
|
+
modrinth4py/types.py,sha256=fEtqSPbFgkO_RejCuRotEYntZCnNWlcNFo5WShOnXGI,4058
|
|
5
|
+
modrinth4py-0.0.1.dist-info/licenses/LICENSE,sha256=IL7dz7u4PKqCktqBnjQPytohvbRCQHJGJOKt47Fdr4g,1078
|
|
6
|
+
modrinth4py-0.0.1.dist-info/METADATA,sha256=lGrUnp8XZoa2lqJMLteFKrpAN4P6aYD2h5OqRy3yewk,2822
|
|
7
|
+
modrinth4py-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
modrinth4py-0.0.1.dist-info/top_level.txt,sha256=1b3U9jcyKxeA-JEDg-XY7zm6kmRAI12ggEY2ufgndlI,12
|
|
9
|
+
modrinth4py-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 rotgruengelb
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
modrinth4py
|