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,118 @@
1
+ """Download targets and hash checks."""
2
+
3
+ import hashlib
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import IO
8
+
9
+ HASH_PRIORITY = ("sha512", "sha256", "sha1", "md5")
10
+
11
+
12
+ def file_hash(path: Path, algorithm: str = "sha1") -> str:
13
+ """Hash a file without loading it all into memory.
14
+
15
+ Args:
16
+ path: The file.
17
+ algorithm: A name that `hashlib.new` accepts.
18
+
19
+ Returns:
20
+ The hexadecimal digest.
21
+ """
22
+ with open(path, "rb") as file:
23
+ return stream_hash(file, algorithm)
24
+
25
+
26
+ def stream_hash(file: IO[bytes], algorithm: str = "sha1") -> str:
27
+ """Hash an open file without loading it all into memory.
28
+
29
+ Args:
30
+ file: The open file.
31
+ algorithm: A name that `hashlib.new` accepts.
32
+
33
+ Returns:
34
+ The hexadecimal digest.
35
+ """
36
+ digest = hashlib.new(algorithm)
37
+ for chunk in iter(lambda: file.read(1 << 20), b""):
38
+ digest.update(chunk)
39
+ return digest.hexdigest()
40
+
41
+
42
+ def strongest_hash(hashes: Mapping[str, str]) -> tuple[str, str] | None:
43
+ """Pick the strongest known hash.
44
+
45
+ Args:
46
+ hashes: Digests keyed by algorithm name.
47
+
48
+ Returns:
49
+ The algorithm and its lowercase digest, or None if none is known.
50
+ """
51
+ for algorithm in HASH_PRIORITY:
52
+ value = hashes.get(algorithm)
53
+ if value:
54
+ return algorithm, value.lower()
55
+ return None
56
+
57
+
58
+ def matches(path: Path, hashes: Mapping[str, str]) -> bool:
59
+ """Check a file against its strongest hash.
60
+
61
+ Args:
62
+ path: The file.
63
+ hashes: Digests keyed by algorithm name.
64
+
65
+ Returns:
66
+ True if the digest matches, or if no known hash was given.
67
+ """
68
+ expected = strongest_hash(hashes)
69
+ return expected is None or file_hash(path, expected[0]) == expected[1]
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Resource:
74
+ """A file to download: where from, where to, and how to verify it.
75
+
76
+ Attributes:
77
+ urls: Download locations, tried in order.
78
+ path: The destination.
79
+ hashes: Expected digests keyed by algorithm name.
80
+ size: The expected size in bytes, or None if unknown.
81
+ """
82
+
83
+ urls: tuple[str, ...]
84
+ path: Path
85
+ hashes: Mapping[str, str] = field(default_factory=dict[str, str], hash=False)
86
+ size: int | None = None
87
+
88
+ @classmethod
89
+ def of(
90
+ cls,
91
+ url: str,
92
+ path: Path,
93
+ hashes: Mapping[str, str] | None = None,
94
+ size: int | None = None,
95
+ ) -> "Resource":
96
+ """Create a resource with one URL.
97
+
98
+ Args:
99
+ url: The download location.
100
+ path: The destination.
101
+ hashes: Expected digests keyed by algorithm name.
102
+ size: The expected size in bytes, or None if unknown.
103
+
104
+ Returns:
105
+ The resource.
106
+ """
107
+ return cls((url,), path, dict(hashes or {}), size)
108
+
109
+ def is_present(self) -> bool:
110
+ """Return whether the destination already passes the size and hash checks.
111
+
112
+ With no size or hashes to check, any existing file counts as present.
113
+ """
114
+ if not self.path.is_file():
115
+ return False
116
+ if self.size is not None and self.path.stat().st_size != self.size:
117
+ return False
118
+ return matches(self.path, self.hashes)
@@ -0,0 +1,241 @@
1
+ """HTTP session with retries, shared by everything that uses the network."""
2
+
3
+ import threading
4
+ import time
5
+ from collections.abc import Collection, Generator, Mapping
6
+ from contextlib import contextmanager
7
+ from importlib.metadata import PackageNotFoundError, version
8
+ from types import TracebackType
9
+ from typing import Any, Self
10
+
11
+ import requests
12
+
13
+ from ..errors import NetworkError
14
+
15
+ try:
16
+ __version__ = version("modsmith")
17
+ except PackageNotFoundError:
18
+ __version__ = "0.0.0"
19
+
20
+ DEFAULT_USER_AGENT = f"modsmith/{__version__} (https://github.com/PaPa-jun/ModSmith)"
21
+ DEFAULT_TIMEOUT = (10.0, 60.0)
22
+ MAX_RETRY_AFTER = 60.0
23
+ RETRY_STATUS = frozenset({429, 500, 502, 503, 504})
24
+ TRANSIENT_ERRORS = (
25
+ requests.ConnectionError,
26
+ requests.Timeout,
27
+ requests.exceptions.ChunkedEncodingError,
28
+ )
29
+
30
+
31
+ def is_retryable(error: Exception) -> bool:
32
+ """Return whether a failed request is worth repeating.
33
+
34
+ Connection errors, timeouts, and HTTP 429 and 5xx responses are transient.
35
+ """
36
+ if isinstance(error, NetworkError):
37
+ return error.status is None or error.status in RETRY_STATUS
38
+ return isinstance(error, TRANSIENT_ERRORS)
39
+
40
+
41
+ @contextmanager
42
+ def session_scope(session: "Session | None") -> Generator["Session"]:
43
+ """Yield ``session``, or a temporary `Session` closed on exit."""
44
+ if session is not None:
45
+ yield session
46
+ return
47
+ with Session() as owned:
48
+ yield owned
49
+
50
+
51
+ def retry_delay(headers: Mapping[str, str]) -> float | None:
52
+ """Return the delay a ``Retry-After`` header asks for, capped at one minute.
53
+
54
+ Args:
55
+ headers: The response headers.
56
+
57
+ Returns:
58
+ The delay in seconds, or None if there is no delay in seconds.
59
+ """
60
+ value = headers.get("Retry-After", "")
61
+ return min(float(value), MAX_RETRY_AFTER) if value.isdigit() else None
62
+
63
+
64
+ class Session:
65
+ """HTTP client with retries. Safe to share between threads.
66
+
67
+ Each thread gets its own connection pool. Use it as a context manager, or
68
+ call `close` when you are done.
69
+
70
+ Args:
71
+ user_agent: The ``User-Agent`` header sent with every request.
72
+ attempts: How many times to try each request.
73
+ backoff: Seconds to wait before the first retry. Doubles on each retry.
74
+ timeout: The connect and read timeouts in seconds.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ user_agent: str = DEFAULT_USER_AGENT,
80
+ attempts: int = 4,
81
+ backoff: float = 1.0,
82
+ timeout: tuple[float, float] = DEFAULT_TIMEOUT,
83
+ ) -> None:
84
+ self.user_agent = user_agent
85
+ self.attempts = attempts
86
+ self.backoff = backoff
87
+ self.timeout = timeout
88
+ self._local = threading.local()
89
+ self._lock = threading.Lock()
90
+ self._sessions: list[requests.Session] = []
91
+
92
+ def __enter__(self) -> Self:
93
+ return self
94
+
95
+ def __exit__(
96
+ self,
97
+ exc_type: type[BaseException] | None,
98
+ exc: BaseException | None,
99
+ traceback: TracebackType | None,
100
+ ) -> None:
101
+ self.close()
102
+
103
+ def close(self) -> None:
104
+ """Close the connections of every thread.
105
+
106
+ The session stays usable and reconnects on the next request.
107
+ """
108
+ with self._lock:
109
+ for session in self._sessions:
110
+ session.close()
111
+ self._sessions.clear()
112
+ self._local = threading.local()
113
+
114
+ @property
115
+ def http(self) -> requests.Session:
116
+ """The calling thread's `requests.Session`, created on first use."""
117
+ session: requests.Session | None = getattr(self._local, "session", None)
118
+ if session is None:
119
+ session = requests.Session()
120
+ session.headers["User-Agent"] = self.user_agent
121
+ self._local.session = session
122
+ with self._lock:
123
+ self._sessions.append(session)
124
+ return session
125
+
126
+ def wait(self, attempt: int, delay: float | None = None) -> None:
127
+ """Sleep before a retry.
128
+
129
+ Args:
130
+ attempt: The zero-based index of the retry.
131
+ delay: Seconds the server asked for, instead of the normal backoff.
132
+ """
133
+ time.sleep(self.backoff * 2**attempt if delay is None else delay)
134
+
135
+ def request(
136
+ self, method: str, url: str, *, accept: Collection[int] = (), **kwargs: Any
137
+ ) -> requests.Response:
138
+ """Send a request, retrying transient failures.
139
+
140
+ Retries stop once a response has been accepted, so reading a streamed
141
+ body is not covered. A delay in ``Retry-After`` is respected, up to one
142
+ minute.
143
+
144
+ Args:
145
+ method: The HTTP method.
146
+ url: The URL.
147
+ accept: Error statuses to return instead of raising.
148
+ **kwargs: Passed to `requests.Session.request`.
149
+
150
+ Returns:
151
+ A successful response, or one with a status in ``accept``.
152
+
153
+ Raises:
154
+ NetworkError: The last attempt failed or returned an error status.
155
+ """
156
+ kwargs.setdefault("timeout", self.timeout)
157
+ last = NetworkError(f"{method} {url} was not attempted")
158
+ delay: float | None = None
159
+ for attempt in range(self.attempts):
160
+ if attempt:
161
+ self.wait(attempt - 1, delay)
162
+ delay = None
163
+ try:
164
+ response = self.http.request(method, url, **kwargs)
165
+ except requests.RequestException as e:
166
+ last = NetworkError(f"{method} {url} failed: {e}")
167
+ if not is_retryable(e):
168
+ break
169
+ continue
170
+ if response.ok or response.status_code in accept:
171
+ return response
172
+ delay = retry_delay(response.headers)
173
+ response.close()
174
+ last = NetworkError(
175
+ f"{method} {url} returned {response.status_code}", response.status_code
176
+ )
177
+ if not is_retryable(last):
178
+ break
179
+ raise last
180
+
181
+ def get_json(self, url: str, **kwargs: Any) -> Any:
182
+ """Send a GET request and decode the JSON response.
183
+
184
+ Args:
185
+ url: The URL.
186
+ **kwargs: Passed to `requests.Session.request`.
187
+
188
+ Returns:
189
+ The decoded body.
190
+
191
+ Raises:
192
+ NetworkError: The request failed or the body is not JSON.
193
+ """
194
+ return self._json(self.request("GET", url, **kwargs), url)
195
+
196
+ def post_json(self, url: str, **kwargs: Any) -> Any:
197
+ """Send a POST request and decode the JSON response.
198
+
199
+ Args:
200
+ url: The URL.
201
+ **kwargs: Passed to `requests.Session.request`.
202
+
203
+ Returns:
204
+ The decoded body.
205
+
206
+ Raises:
207
+ NetworkError: The request failed or the body is not JSON.
208
+ """
209
+ return self._json(self.request("POST", url, **kwargs), url)
210
+
211
+ def stream(
212
+ self,
213
+ url: str,
214
+ headers: Mapping[str, str] | None = None,
215
+ *,
216
+ accept: Collection[int] = (),
217
+ ) -> requests.Response:
218
+ """Open a streaming GET request, with the same retries as `request`.
219
+
220
+ Failures while reading the body are not retried, because the caller
221
+ has to decide how to resume.
222
+
223
+ Args:
224
+ url: The URL.
225
+ headers: Extra request headers.
226
+ accept: Error statuses to return instead of raising.
227
+
228
+ Returns:
229
+ The open response. Use it as a context manager.
230
+
231
+ Raises:
232
+ NetworkError: The last attempt failed or returned an error status.
233
+ """
234
+ return self.request("GET", url, accept=accept, headers=headers, stream=True)
235
+
236
+ def _json(self, response: requests.Response, url: str) -> Any:
237
+ """Decode a JSON response, raising `NetworkError` if it is invalid."""
238
+ try:
239
+ return response.json()
240
+ except ValueError as e:
241
+ raise NetworkError(f"Invalid JSON from {url}") from e
@@ -0,0 +1,18 @@
1
+ """Read modpack archives. `load` detects the format from the archive."""
2
+
3
+ from ._formats import CurseForgePack, Mrpack, PackFormat, load, register
4
+ from ._model import FileRef, Modpack, PackEntry, Side, Support, safe_relative
5
+
6
+ __all__ = [
7
+ "CurseForgePack",
8
+ "FileRef",
9
+ "Modpack",
10
+ "Mrpack",
11
+ "PackEntry",
12
+ "PackFormat",
13
+ "Side",
14
+ "Support",
15
+ "load",
16
+ "register",
17
+ "safe_relative",
18
+ ]
@@ -0,0 +1,262 @@
1
+ """Modpack archive formats, and detecting which one an archive uses."""
2
+
3
+ import json
4
+ import zipfile
5
+ from abc import ABC, abstractmethod
6
+ from pathlib import Path
7
+ from typing import Any, ClassVar, Literal
8
+
9
+ from pydantic import BaseModel, Field, ValidationError
10
+
11
+ from ..errors import PackError, UnsupportedError
12
+ from ._model import FileRef, Modpack, PackEntry, Support, safe_relative
13
+
14
+
15
+ class PackFormat(ABC):
16
+ """Reads one archive format. Subclass it and `register` it to add another.
17
+
18
+ Attributes:
19
+ name: The format's name.
20
+ index_file: The file in the archive that identifies this format.
21
+ """
22
+
23
+ name: ClassVar[str]
24
+ index_file: ClassVar[str]
25
+
26
+ @abstractmethod
27
+ def parse(self, index: dict[str, Any], path: Path) -> Modpack:
28
+ """Build a `Modpack` from the archive's decoded index file.
29
+
30
+ Args:
31
+ index: The decoded index file.
32
+ path: The archive.
33
+
34
+ Returns:
35
+ The modpack.
36
+
37
+ Raises:
38
+ PackError: The index is invalid.
39
+ """
40
+
41
+
42
+ _formats: list[PackFormat] = []
43
+
44
+
45
+ def register(format_: PackFormat) -> PackFormat:
46
+ """Add a format. The most recently registered format is tried first.
47
+
48
+ Args:
49
+ format_: The format.
50
+
51
+ Returns:
52
+ The same format.
53
+ """
54
+ _formats.insert(0, format_)
55
+ return format_
56
+
57
+
58
+ def load(path: Path | str) -> Modpack:
59
+ """Read a modpack archive. Needs no network and no API key.
60
+
61
+ Args:
62
+ path: The archive.
63
+
64
+ Returns:
65
+ The modpack. CurseForge packs still need `Platform.resolve`.
66
+
67
+ Raises:
68
+ PackError: The archive is invalid or its format is unknown.
69
+ UnsupportedError: The pack is for another game.
70
+ """
71
+ archive_path = Path(path)
72
+ try:
73
+ with zipfile.ZipFile(archive_path) as archive:
74
+ names = set(archive.namelist())
75
+ for format_ in _formats:
76
+ if format_.index_file in names:
77
+ index = json.loads(archive.read(format_.index_file))
78
+ return format_.parse(index, archive_path)
79
+ except (OSError, zipfile.BadZipFile, ValueError) as e:
80
+ raise PackError(f"{archive_path.name} is not a valid modpack: {e}") from e
81
+ raise PackError(f"{archive_path.name} is not a known modpack format")
82
+
83
+
84
+ class _MrpackEnv(BaseModel):
85
+ """A file's ``env`` object in ``modrinth.index.json``."""
86
+
87
+ client: Support = "required"
88
+ server: Support = "required"
89
+
90
+
91
+ class _MrpackFile(BaseModel):
92
+ """An entry of ``files`` in ``modrinth.index.json``."""
93
+
94
+ path: str
95
+ hashes: dict[str, str]
96
+ downloads: list[str]
97
+ file_size: int | None = Field(default=None, alias="fileSize")
98
+ env: _MrpackEnv = Field(default_factory=_MrpackEnv)
99
+
100
+
101
+ class _MrpackIndex(BaseModel):
102
+ """The contents of ``modrinth.index.json``."""
103
+
104
+ format_version: int = Field(alias="formatVersion")
105
+ game: str
106
+ name: str
107
+ version_id: str = Field(alias="versionId")
108
+ files: list[_MrpackFile]
109
+ dependencies: dict[str, str]
110
+
111
+
112
+ class Mrpack(PackFormat):
113
+ """The Modrinth ``.mrpack`` format.
114
+
115
+ Attributes:
116
+ roots: The folders that hold overrides, keyed by side.
117
+ """
118
+
119
+ name = "mrpack"
120
+ index_file = "modrinth.index.json"
121
+ roots: ClassVar[dict[str, str]] = {
122
+ "common": "overrides",
123
+ "client": "client-overrides",
124
+ "server": "server-overrides",
125
+ }
126
+
127
+ def parse(self, index: dict[str, Any], path: Path) -> Modpack:
128
+ """Build a `Modpack` from ``modrinth.index.json``.
129
+
130
+ Args:
131
+ index: The decoded index file.
132
+ path: The archive.
133
+
134
+ Returns:
135
+ The modpack. Its entries are already resolved.
136
+
137
+ Raises:
138
+ PackError: The index is invalid or does not name a game version or loader.
139
+ UnsupportedError: The pack is for another game.
140
+ """
141
+ try:
142
+ data = _MrpackIndex.model_validate(index)
143
+ except ValidationError as e:
144
+ raise PackError(f"Invalid {self.index_file}: {e}") from e
145
+ if data.game != "minecraft":
146
+ raise UnsupportedError(f"Unsupported game: {data.game}")
147
+ dependencies = dict(data.dependencies)
148
+ minecraft = dependencies.pop("minecraft", None)
149
+ if minecraft is None:
150
+ raise PackError("Modpack does not declare a Minecraft version")
151
+ if not dependencies:
152
+ raise PackError("Modpack does not declare a mod loader")
153
+ loader_kind, loader_version = next(iter(dependencies.items()))
154
+ return Modpack(
155
+ format=self.name,
156
+ name=data.name,
157
+ version=data.version_id,
158
+ minecraft=minecraft,
159
+ loader_kind=loader_kind,
160
+ loader_version=loader_version,
161
+ entries=tuple(
162
+ PackEntry(
163
+ path=safe_relative(file.path),
164
+ urls=tuple(file.downloads),
165
+ hashes=file.hashes,
166
+ size=file.file_size,
167
+ client=file.env.client,
168
+ server=file.env.server,
169
+ )
170
+ for file in data.files
171
+ ),
172
+ archive=path,
173
+ override_roots=self.roots,
174
+ )
175
+
176
+
177
+ class _CfFile(BaseModel):
178
+ """An entry of ``files`` in a CurseForge ``manifest.json``."""
179
+
180
+ project_id: int = Field(alias="projectID")
181
+ file_id: int = Field(alias="fileID")
182
+ required: bool = True
183
+
184
+
185
+ class _CfLoader(BaseModel):
186
+ """An entry of ``modLoaders`` in a CurseForge ``manifest.json``."""
187
+
188
+ id: str
189
+ primary: bool = False
190
+
191
+
192
+ class _CfMinecraft(BaseModel):
193
+ """The ``minecraft`` object in a CurseForge ``manifest.json``."""
194
+
195
+ version: str
196
+ mod_loaders: list[_CfLoader] = Field(alias="modLoaders")
197
+ recommended_ram: int | None = Field(default=None, alias="recommendedRam")
198
+
199
+
200
+ class _CfManifest(BaseModel):
201
+ """The contents of a CurseForge ``manifest.json``."""
202
+
203
+ manifest_type: Literal["minecraftModpack"] = Field(alias="manifestType")
204
+ name: str = "Modpack"
205
+ version: str = ""
206
+ overrides: str = "overrides"
207
+ minecraft: _CfMinecraft
208
+ files: list[_CfFile] = Field(default_factory=list[_CfFile])
209
+
210
+
211
+ class CurseForgePack(PackFormat):
212
+ """The CurseForge ``.zip`` format."""
213
+
214
+ name = "curseforge"
215
+ index_file = "manifest.json"
216
+
217
+ def parse(self, index: dict[str, Any], path: Path) -> Modpack:
218
+ """Build a `Modpack` from ``manifest.json``.
219
+
220
+ Args:
221
+ index: The decoded index file.
222
+ path: The archive.
223
+
224
+ Returns:
225
+ The modpack. Its entries need `CurseForge.resolve`.
226
+
227
+ Raises:
228
+ PackError: The manifest is invalid or does not name a mod loader.
229
+ """
230
+ try:
231
+ data = _CfManifest.model_validate(index)
232
+ except ValidationError as e:
233
+ raise PackError(f"Invalid {self.index_file}: {e}") from e
234
+ loaders = data.minecraft.mod_loaders
235
+ if not loaders:
236
+ raise PackError("Modpack does not declare a mod loader")
237
+ loader = next((item for item in loaders if item.primary), loaders[0])
238
+ kind, _, version = loader.id.partition("-")
239
+ if not version:
240
+ raise PackError(f"Invalid mod loader id: {loader.id}")
241
+
242
+ def entry(file: _CfFile) -> PackEntry:
243
+ support: Support = "required" if file.required else "optional"
244
+ ref = FileRef(self.name, str(file.project_id), str(file.file_id))
245
+ return PackEntry(client=support, server=support, ref=ref)
246
+
247
+ return Modpack(
248
+ format=self.name,
249
+ name=data.name,
250
+ version=data.version,
251
+ minecraft=data.minecraft.version,
252
+ loader_kind=kind,
253
+ loader_version=version,
254
+ entries=tuple(entry(file) for file in data.files),
255
+ archive=path,
256
+ override_roots={"common": data.overrides},
257
+ memory_mb=data.minecraft.recommended_ram,
258
+ )
259
+
260
+
261
+ register(CurseForgePack())
262
+ register(Mrpack())