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,168 @@
|
|
|
1
|
+
"""The layout of a game directory and its version files."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .._support import write_json
|
|
11
|
+
from ..errors import UnsupportedError
|
|
12
|
+
from ._maven import maven_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def default_game_path() -> Path:
|
|
16
|
+
"""Return the official launcher's default game directory for this system.
|
|
17
|
+
|
|
18
|
+
On Linux, the Flatpak directory is used if only that one exists.
|
|
19
|
+
"""
|
|
20
|
+
home = Path.home()
|
|
21
|
+
if sys.platform == "win32":
|
|
22
|
+
appdata = os.getenv("APPDATA")
|
|
23
|
+
return Path(appdata, ".minecraft") if appdata else home / ".minecraft"
|
|
24
|
+
if sys.platform == "darwin":
|
|
25
|
+
return home / "Library" / "Application Support" / "minecraft"
|
|
26
|
+
default = home / ".minecraft"
|
|
27
|
+
flatpak = home / ".var" / "app" / "com.mojang.Minecraft" / ".minecraft"
|
|
28
|
+
return flatpak if not default.exists() and flatpak.exists() else default
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Version:
|
|
33
|
+
"""A parsed version JSON, vanilla or from a mod loader.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
id: The version id.
|
|
37
|
+
inherits_from: The parent version of a loader version, or None.
|
|
38
|
+
java_component: The Mojang runtime it asks for, or None.
|
|
39
|
+
java_major: The major Java version it asks for, or None.
|
|
40
|
+
client_url: Where to download the client jar, or None.
|
|
41
|
+
client_sha1: The client jar's SHA-1, or None.
|
|
42
|
+
client_size: The client jar's size in bytes, or None.
|
|
43
|
+
raw: The original JSON data.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
id: str
|
|
47
|
+
inherits_from: str | None = None
|
|
48
|
+
java_component: str | None = None
|
|
49
|
+
java_major: int | None = None
|
|
50
|
+
client_url: str | None = None
|
|
51
|
+
client_sha1: str | None = None
|
|
52
|
+
client_size: int | None = None
|
|
53
|
+
raw: dict[str, Any] = field(
|
|
54
|
+
default_factory=dict[str, Any], repr=False, compare=False
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_dict(cls, data: dict[str, Any]) -> "Version":
|
|
59
|
+
"""Build a version from decoded JSON.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
data: The version JSON.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The parsed version.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
KeyError: The data has no ``id``.
|
|
69
|
+
"""
|
|
70
|
+
java: dict[str, Any] = data.get("javaVersion") or {}
|
|
71
|
+
downloads: dict[str, Any] = data.get("downloads") or {}
|
|
72
|
+
client: dict[str, Any] = downloads.get("client") or {}
|
|
73
|
+
return cls(
|
|
74
|
+
id=data["id"],
|
|
75
|
+
inherits_from=data.get("inheritsFrom"),
|
|
76
|
+
java_component=java.get("component"),
|
|
77
|
+
java_major=java.get("majorVersion"),
|
|
78
|
+
client_url=client.get("url"),
|
|
79
|
+
client_sha1=client.get("sha1"),
|
|
80
|
+
client_size=client.get("size"),
|
|
81
|
+
raw=data,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def load(cls, path: Path) -> "Version":
|
|
86
|
+
"""Read a version from a JSON file."""
|
|
87
|
+
return cls.from_dict(json.loads(path.read_text(encoding="utf-8")))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class GameDir:
|
|
91
|
+
"""A ``.minecraft`` directory and the layout of the files inside it.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
path: The directory. It does not have to exist. A relative path is made
|
|
95
|
+
absolute, so it stays valid if the working directory changes.
|
|
96
|
+
|
|
97
|
+
Attributes:
|
|
98
|
+
path: The absolute path of the directory.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, path: Path | str) -> None:
|
|
102
|
+
self.path = Path(path).resolve()
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def default(cls) -> "GameDir":
|
|
106
|
+
"""Return the official launcher's default directory for this system."""
|
|
107
|
+
return cls(default_game_path())
|
|
108
|
+
|
|
109
|
+
def __repr__(self) -> str:
|
|
110
|
+
return f"GameDir({str(self.path)!r})"
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def versions_dir(self) -> Path:
|
|
114
|
+
"""The directory that holds installed versions."""
|
|
115
|
+
return self.path / "versions"
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def libraries_dir(self) -> Path:
|
|
119
|
+
"""The directory that holds libraries."""
|
|
120
|
+
return self.path / "libraries"
|
|
121
|
+
|
|
122
|
+
def version_json(self, version_id: str) -> Path:
|
|
123
|
+
"""Return the path of a version's JSON file."""
|
|
124
|
+
return self.versions_dir / version_id / f"{version_id}.json"
|
|
125
|
+
|
|
126
|
+
def client_jar(self, version_id: str) -> Path:
|
|
127
|
+
"""Return the path of a version's client jar."""
|
|
128
|
+
return self.versions_dir / version_id / f"{version_id}.jar"
|
|
129
|
+
|
|
130
|
+
def library(self, coord: str) -> Path:
|
|
131
|
+
"""Return the path of the library with a Maven coordinate."""
|
|
132
|
+
return self.libraries_dir / maven_path(coord)
|
|
133
|
+
|
|
134
|
+
def has_version(self, version_id: str) -> bool:
|
|
135
|
+
"""Return whether a version's JSON file exists."""
|
|
136
|
+
return self.version_json(version_id).is_file()
|
|
137
|
+
|
|
138
|
+
def installed_versions(self) -> list[str]:
|
|
139
|
+
"""List the installed version ids, sorted by name."""
|
|
140
|
+
if not self.versions_dir.is_dir():
|
|
141
|
+
return []
|
|
142
|
+
names = sorted(path.name for path in self.versions_dir.iterdir())
|
|
143
|
+
return [name for name in names if self.has_version(name)]
|
|
144
|
+
|
|
145
|
+
def version(self, version_id: str) -> Version:
|
|
146
|
+
"""Read an installed version.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
version_id: The version id.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
The parsed version.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
UnsupportedError: The version is not installed.
|
|
156
|
+
"""
|
|
157
|
+
if not self.has_version(version_id):
|
|
158
|
+
raise UnsupportedError(f"Version {version_id} is not installed")
|
|
159
|
+
return Version.load(self.version_json(version_id))
|
|
160
|
+
|
|
161
|
+
def write_version(self, version_id: str, data: dict[str, Any]) -> None:
|
|
162
|
+
"""Write a version's JSON file.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
version_id: The version id.
|
|
166
|
+
data: The version JSON.
|
|
167
|
+
"""
|
|
168
|
+
write_json(self.version_json(version_id), data)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Maven coordinates, used to locate library files."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class MavenCoord:
|
|
8
|
+
"""A parsed coordinate such as ``group:artifact:version:classifier@extension``.
|
|
9
|
+
|
|
10
|
+
Attributes:
|
|
11
|
+
group: The group id.
|
|
12
|
+
artifact: The artifact id.
|
|
13
|
+
version: The version.
|
|
14
|
+
classifier: The classifier, or None.
|
|
15
|
+
extension: The file extension.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
group: str
|
|
19
|
+
artifact: str
|
|
20
|
+
version: str
|
|
21
|
+
classifier: str | None = None
|
|
22
|
+
extension: str = "jar"
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def parse(cls, coord: str) -> "MavenCoord":
|
|
26
|
+
"""Parse a coordinate.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
coord: The coordinate. The classifier and extension are optional.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The parsed coordinate.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ValueError: The coordinate is malformed.
|
|
36
|
+
"""
|
|
37
|
+
body, _, extension = coord.partition("@")
|
|
38
|
+
parts = body.split(":")
|
|
39
|
+
if not 3 <= len(parts) <= 4 or "@" in extension:
|
|
40
|
+
raise ValueError(f"Invalid maven coordinate: {coord}")
|
|
41
|
+
return cls(
|
|
42
|
+
group=parts[0],
|
|
43
|
+
artifact=parts[1],
|
|
44
|
+
version=parts[2],
|
|
45
|
+
classifier=parts[3] if len(parts) == 4 else None,
|
|
46
|
+
extension=extension or "jar",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def path(self) -> str:
|
|
51
|
+
"""The file's path inside a Maven repository."""
|
|
52
|
+
name = f"{self.artifact}-{self.version}"
|
|
53
|
+
if self.classifier:
|
|
54
|
+
name += f"-{self.classifier}"
|
|
55
|
+
group = self.group.replace(".", "/")
|
|
56
|
+
return f"{group}/{self.artifact}/{self.version}/{name}.{self.extension}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def maven_path(coord: str) -> str:
|
|
60
|
+
"""Convert a coordinate to its path inside a Maven repository.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
coord: The coordinate.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The relative path, with forward slashes.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ValueError: The coordinate is malformed.
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
>>> maven_path("net.fabricmc:fabric-loader:0.16.10")
|
|
73
|
+
'net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar'
|
|
74
|
+
"""
|
|
75
|
+
return MavenCoord.parse(coord).path
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Mojang's version manifest and vanilla game downloads."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from ..errors import UnsupportedError
|
|
6
|
+
from ..net import Resource, Session, download
|
|
7
|
+
from ..net._session import session_scope
|
|
8
|
+
from ..progress import CancelToken, ProgressCallback
|
|
9
|
+
from ._game_dir import GameDir, Version
|
|
10
|
+
|
|
11
|
+
VERSION_MANIFEST_URL = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class VersionInfo:
|
|
16
|
+
"""One entry in Mojang's version manifest.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
id: The version id.
|
|
20
|
+
type: The kind of version, such as ``release`` or ``snapshot``.
|
|
21
|
+
url: Where to download the version JSON.
|
|
22
|
+
sha1: The version JSON's SHA-1.
|
|
23
|
+
release_time: When the version was released, in ISO 8601 format.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
id: str
|
|
27
|
+
type: str
|
|
28
|
+
url: str
|
|
29
|
+
sha1: str
|
|
30
|
+
release_time: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def list_versions(
|
|
34
|
+
session: Session | None = None, *, manifest_url: str = VERSION_MANIFEST_URL
|
|
35
|
+
) -> list[VersionInfo]:
|
|
36
|
+
"""List every Minecraft version Mojang publishes, newest first.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
session: The session to use. A temporary one is created if omitted.
|
|
40
|
+
manifest_url: Where to read the manifest.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The manifest's entries.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
NetworkError: The manifest could not be fetched.
|
|
47
|
+
"""
|
|
48
|
+
with session_scope(session) as client:
|
|
49
|
+
manifest = client.get_json(manifest_url)
|
|
50
|
+
return [
|
|
51
|
+
VersionInfo(v["id"], v["type"], v["url"], v["sha1"], v["releaseTime"])
|
|
52
|
+
for v in manifest["versions"]
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def install_version(
|
|
57
|
+
game: GameDir,
|
|
58
|
+
version_id: str,
|
|
59
|
+
*,
|
|
60
|
+
session: Session | None = None,
|
|
61
|
+
progress: ProgressCallback | None = None,
|
|
62
|
+
cancel: CancelToken | None = None,
|
|
63
|
+
manifest_url: str = VERSION_MANIFEST_URL,
|
|
64
|
+
) -> Version:
|
|
65
|
+
"""Download a vanilla version's JSON and client jar into ``game``.
|
|
66
|
+
|
|
67
|
+
Files that are already valid are kept.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
game: The game directory.
|
|
71
|
+
version_id: The Minecraft version.
|
|
72
|
+
session: The session to use. A temporary one is created if omitted.
|
|
73
|
+
progress: Receives updates while the client jar downloads.
|
|
74
|
+
cancel: Stops the download.
|
|
75
|
+
manifest_url: Where to read Mojang's version manifest.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The installed version.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
UnsupportedError: The version is unknown or has no client download.
|
|
82
|
+
NetworkError: The manifest could not be fetched.
|
|
83
|
+
DownloadError: A file could not be downloaded.
|
|
84
|
+
Cancelled: ``cancel`` was triggered.
|
|
85
|
+
"""
|
|
86
|
+
with session_scope(session) as client:
|
|
87
|
+
if not game.has_version(version_id):
|
|
88
|
+
info = next(
|
|
89
|
+
(
|
|
90
|
+
v
|
|
91
|
+
for v in list_versions(client, manifest_url=manifest_url)
|
|
92
|
+
if v.id == version_id
|
|
93
|
+
),
|
|
94
|
+
None,
|
|
95
|
+
)
|
|
96
|
+
if info is None:
|
|
97
|
+
raise UnsupportedError(f"Unknown Minecraft version: {version_id}")
|
|
98
|
+
meta = Resource.of(
|
|
99
|
+
info.url, game.version_json(version_id), {"sha1": info.sha1}
|
|
100
|
+
)
|
|
101
|
+
download(meta, session=client, cancel=cancel)
|
|
102
|
+
|
|
103
|
+
version = game.version(version_id)
|
|
104
|
+
if version.client_url is None:
|
|
105
|
+
raise UnsupportedError(f"Version {version_id} has no client download")
|
|
106
|
+
jar = Resource.of(
|
|
107
|
+
version.client_url,
|
|
108
|
+
game.client_jar(version_id),
|
|
109
|
+
{"sha1": version.client_sha1} if version.client_sha1 else None,
|
|
110
|
+
version.client_size,
|
|
111
|
+
)
|
|
112
|
+
download(
|
|
113
|
+
jar, session=client, progress=progress, cancel=cancel, task="minecraft"
|
|
114
|
+
)
|
|
115
|
+
return version
|
modsmith/net/__init__.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Parallel downloads with verification and resume."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .._support import replace_file
|
|
13
|
+
from ..errors import Cancelled, DownloadError, IntegrityError, NetworkError
|
|
14
|
+
from ..progress import CancelToken, ProgressCallback, report
|
|
15
|
+
from ._resource import Resource, matches, strongest_hash
|
|
16
|
+
from ._session import Session, is_retryable
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
CHUNK_SIZE = 65536
|
|
20
|
+
OnBytes = Callable[[int], None]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Meter:
|
|
24
|
+
"""Tracks how many bytes of one file count toward the overall progress."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, on_bytes: OnBytes) -> None:
|
|
27
|
+
self.on_bytes = on_bytes
|
|
28
|
+
self.counted = 0
|
|
29
|
+
|
|
30
|
+
def add(self, count: int) -> None:
|
|
31
|
+
"""Count more bytes. A negative count takes bytes back."""
|
|
32
|
+
if count:
|
|
33
|
+
self.counted += count
|
|
34
|
+
self.on_bytes(count)
|
|
35
|
+
|
|
36
|
+
def reset(self, count: int) -> None:
|
|
37
|
+
"""Set the count, for a file that was skipped, resumed or restarted."""
|
|
38
|
+
self.add(count - self.counted)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _verify(resource: Resource, part: Path) -> None:
|
|
42
|
+
"""Check a finished ``.part`` file, raising `IntegrityError` if it is wrong."""
|
|
43
|
+
if resource.size is not None and part.stat().st_size != resource.size:
|
|
44
|
+
raise IntegrityError(f"Size mismatch for {resource.path.name}")
|
|
45
|
+
if not matches(part, resource.hashes):
|
|
46
|
+
raise IntegrityError(f"Hash mismatch for {resource.path.name}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _range_start(response: requests.Response) -> int | None:
|
|
50
|
+
"""Return where a partial response starts, if it says."""
|
|
51
|
+
match = re.match(r"bytes (\d+)-", response.headers.get("Content-Range", ""))
|
|
52
|
+
return int(match[1]) if match else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _validator(response: requests.Response) -> str | None:
|
|
56
|
+
"""Return a value that identifies this version of a file for ``If-Range``."""
|
|
57
|
+
etag = response.headers.get("ETag")
|
|
58
|
+
# ``If-Range`` only works with strong validators.
|
|
59
|
+
if etag and not etag.startswith("W/"):
|
|
60
|
+
return etag
|
|
61
|
+
return response.headers.get("Last-Modified")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _stream(
|
|
65
|
+
url: str,
|
|
66
|
+
resource: Resource,
|
|
67
|
+
part: Path,
|
|
68
|
+
session: Session,
|
|
69
|
+
meter: _Meter,
|
|
70
|
+
cancel: CancelToken | None,
|
|
71
|
+
validators: dict[str, str],
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Fetch one URL into ``part``, continuing from what is already there.
|
|
74
|
+
|
|
75
|
+
``validators`` remembers which version of the file each URL served, so a
|
|
76
|
+
resume cannot join two versions.
|
|
77
|
+
"""
|
|
78
|
+
offset = part.stat().st_size if part.exists() else 0
|
|
79
|
+
if resource.size is not None and offset >= resource.size:
|
|
80
|
+
part.touch() # an empty file has nothing to stream
|
|
81
|
+
meter.reset(offset)
|
|
82
|
+
return
|
|
83
|
+
headers: dict[str, str] = {}
|
|
84
|
+
if offset:
|
|
85
|
+
headers["Range"] = f"bytes={offset}-"
|
|
86
|
+
if url in validators:
|
|
87
|
+
headers["If-Range"] = validators[url]
|
|
88
|
+
with session.stream(url, headers, accept=(416,)) as response:
|
|
89
|
+
if response.status_code == 416:
|
|
90
|
+
raise IntegrityError(f"Stale partial file for {url}")
|
|
91
|
+
# A 200 means the server sent the whole file, either because it ignored
|
|
92
|
+
# the range or because the file changed.
|
|
93
|
+
resuming = offset > 0 and response.status_code == 206
|
|
94
|
+
if resuming and _range_start(response) not in (None, offset):
|
|
95
|
+
raise IntegrityError(f"Wrong range from {url}")
|
|
96
|
+
validator = _validator(response)
|
|
97
|
+
if validator:
|
|
98
|
+
validators[url] = validator
|
|
99
|
+
meter.reset(offset if resuming else 0)
|
|
100
|
+
with open(part, "ab" if resuming else "wb") as file:
|
|
101
|
+
for chunk in response.iter_content(CHUNK_SIZE):
|
|
102
|
+
if cancel is not None:
|
|
103
|
+
cancel.check()
|
|
104
|
+
file.write(chunk)
|
|
105
|
+
meter.add(len(chunk))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _fetch(
|
|
109
|
+
resource: Resource,
|
|
110
|
+
session: Session,
|
|
111
|
+
on_bytes: OnBytes,
|
|
112
|
+
cancel: CancelToken | None,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Download one resource, trying each URL in turn.
|
|
115
|
+
|
|
116
|
+
`Session.stream` has already retried a failure to connect, so that moves on
|
|
117
|
+
to the next URL. Only a broken transfer or a bad file is retried here.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
DownloadError: Every URL failed.
|
|
121
|
+
Cancelled: ``cancel`` was triggered.
|
|
122
|
+
"""
|
|
123
|
+
meter = _Meter(on_bytes)
|
|
124
|
+
if resource.is_present():
|
|
125
|
+
meter.reset(resource.size or 0)
|
|
126
|
+
return
|
|
127
|
+
if not resource.urls:
|
|
128
|
+
raise DownloadError(f"{resource.path.name} has no download URL")
|
|
129
|
+
resource.path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
part = resource.path.with_name(resource.path.name + ".part")
|
|
131
|
+
# Without a hash, nothing could prove a leftover file is the same version.
|
|
132
|
+
if strongest_hash(resource.hashes) is None:
|
|
133
|
+
part.unlink(missing_ok=True)
|
|
134
|
+
|
|
135
|
+
validators: dict[str, str] = {}
|
|
136
|
+
last: Exception | None = None
|
|
137
|
+
for url in resource.urls:
|
|
138
|
+
for attempt in range(session.attempts):
|
|
139
|
+
if attempt:
|
|
140
|
+
session.wait(attempt - 1)
|
|
141
|
+
if cancel is not None:
|
|
142
|
+
cancel.check()
|
|
143
|
+
try:
|
|
144
|
+
_stream(url, resource, part, session, meter, cancel, validators)
|
|
145
|
+
_verify(resource, part)
|
|
146
|
+
except IntegrityError as e:
|
|
147
|
+
part.unlink(missing_ok=True)
|
|
148
|
+
last = e
|
|
149
|
+
continue
|
|
150
|
+
except NetworkError as e:
|
|
151
|
+
last = e
|
|
152
|
+
break
|
|
153
|
+
except requests.RequestException as e:
|
|
154
|
+
last = e
|
|
155
|
+
if not is_retryable(e):
|
|
156
|
+
break
|
|
157
|
+
continue
|
|
158
|
+
replace_file(part, resource.path)
|
|
159
|
+
return
|
|
160
|
+
logger.warning("Download from %s failed: %s", url, last)
|
|
161
|
+
raise DownloadError(f"Failed to download {resource.path.name}: {last}") from last
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def download(
|
|
165
|
+
resources: Resource | Iterable[Resource],
|
|
166
|
+
*,
|
|
167
|
+
session: Session | None = None,
|
|
168
|
+
max_workers: int = 8,
|
|
169
|
+
progress: ProgressCallback | None = None,
|
|
170
|
+
cancel: CancelToken | None = None,
|
|
171
|
+
task: str = "download",
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Download resources in parallel, skipping files that are already valid.
|
|
174
|
+
|
|
175
|
+
Interrupted files resume from their ``.part`` file. All files are attempted
|
|
176
|
+
even if some fail. Resources with the same path are downloaded once.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
resources: One resource, or several.
|
|
180
|
+
session: The session to use. A temporary one is created if omitted.
|
|
181
|
+
max_workers: How many files to download at once.
|
|
182
|
+
progress: Called with byte counts if every size is known, otherwise with
|
|
183
|
+
file counts. It may be called from worker threads, one at a time.
|
|
184
|
+
cancel: Stops the downloads and keeps partial files for later.
|
|
185
|
+
task: The task name reported to ``progress``.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
DownloadError: A file failed. `DownloadError.failures` lists every failure.
|
|
189
|
+
Cancelled: ``cancel`` was triggered.
|
|
190
|
+
"""
|
|
191
|
+
items = [resources] if isinstance(resources, Resource) else list(resources)
|
|
192
|
+
unique = list({resource.path: resource for resource in items}.values())
|
|
193
|
+
if session is None:
|
|
194
|
+
with Session() as owned:
|
|
195
|
+
return download(
|
|
196
|
+
unique,
|
|
197
|
+
session=owned,
|
|
198
|
+
max_workers=max_workers,
|
|
199
|
+
progress=progress,
|
|
200
|
+
cancel=cancel,
|
|
201
|
+
task=task,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
sizes = [resource.size for resource in unique]
|
|
205
|
+
by_bytes = bool(unique) and all(size is not None for size in sizes)
|
|
206
|
+
total_bytes = sum(size or 0 for size in sizes)
|
|
207
|
+
lock = threading.Lock()
|
|
208
|
+
transferred = 0
|
|
209
|
+
|
|
210
|
+
def on_bytes(count: int) -> None:
|
|
211
|
+
nonlocal transferred
|
|
212
|
+
if not by_bytes:
|
|
213
|
+
return
|
|
214
|
+
with lock:
|
|
215
|
+
transferred += count
|
|
216
|
+
done = min(max(transferred, 0), total_bytes)
|
|
217
|
+
report(progress, task, done, total_bytes, "byte")
|
|
218
|
+
|
|
219
|
+
failures: list[str] = []
|
|
220
|
+
finished = 0
|
|
221
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
222
|
+
futures: dict[Future[None], Resource] = {
|
|
223
|
+
executor.submit(_fetch, resource, session, on_bytes, cancel): resource
|
|
224
|
+
for resource in unique
|
|
225
|
+
}
|
|
226
|
+
for future in as_completed(futures):
|
|
227
|
+
error = future.exception()
|
|
228
|
+
if isinstance(error, Cancelled):
|
|
229
|
+
for other in futures:
|
|
230
|
+
other.cancel()
|
|
231
|
+
raise error
|
|
232
|
+
if isinstance(error, DownloadError):
|
|
233
|
+
failures.append(str(error))
|
|
234
|
+
elif error is not None:
|
|
235
|
+
raise error
|
|
236
|
+
finished += 1
|
|
237
|
+
if not by_bytes:
|
|
238
|
+
with lock:
|
|
239
|
+
report(progress, task, finished, len(unique), "file")
|
|
240
|
+
if failures:
|
|
241
|
+
raise DownloadError(
|
|
242
|
+
f"{len(failures)} of {len(unique)} downloads failed", failures
|
|
243
|
+
)
|