packsmith 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.
- packsmith/__init__.py +2 -0
- packsmith/__main__.py +6 -0
- packsmith/api/__init__.py +5 -0
- packsmith/api/modrinth.py +140 -0
- packsmith/cli/__init__.py +2 -0
- packsmith/cli/add.py +160 -0
- packsmith/cli/downloader.py +229 -0
- packsmith/cli/export.py +200 -0
- packsmith/cli/resolve.py +98 -0
- packsmith/cli/setup.py +39 -0
- packsmith/cli/ui.py +66 -0
- packsmith/core/__init__.py +2 -0
- packsmith/core/models.py +251 -0
- packsmith/core/solver.py +225 -0
- packsmith/main.py +39 -0
- packsmith-0.1.0.dist-info/METADATA +123 -0
- packsmith-0.1.0.dist-info/RECORD +20 -0
- packsmith-0.1.0.dist-info/WHEEL +4 -0
- packsmith-0.1.0.dist-info/entry_points.txt +2 -0
- packsmith-0.1.0.dist-info/licenses/LICENSE +21 -0
packsmith/__init__.py
ADDED
packsmith/__main__.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Copyright (c) 2026 Frank1o3
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from importlib.metadata import version
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from httpx import AsyncClient, QueryParams, Response
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
from packsmith.core.models import Hit, ProjectVersion, ProjectVersions, Search
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from packsmith.core.models import ProjectType
|
|
21
|
+
|
|
22
|
+
PrimitiveData = str | int | float | bool | None
|
|
23
|
+
|
|
24
|
+
QueryParamTypes = (
|
|
25
|
+
QueryParams
|
|
26
|
+
| Mapping[str, PrimitiveData | Sequence[PrimitiveData]]
|
|
27
|
+
| list[tuple[str, PrimitiveData]]
|
|
28
|
+
| tuple[tuple[str, PrimitiveData], ...]
|
|
29
|
+
| str
|
|
30
|
+
| bytes
|
|
31
|
+
) | None
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RateLimitState(BaseModel):
|
|
37
|
+
limit: int | None = None
|
|
38
|
+
remaining: int | None = None
|
|
39
|
+
reset_after: float | None = None
|
|
40
|
+
last_update: float | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ModrinthClient:
|
|
44
|
+
BASE_URL = "https://api.modrinth.com/v2"
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
user_agent = f"Frank1o3/packsmith/{version('packsmith')} (https://github.com/Frank1o3/packsmith)"
|
|
48
|
+
|
|
49
|
+
self._client = AsyncClient(
|
|
50
|
+
base_url=self.BASE_URL, headers={"User-Agent": user_agent}, timeout=30.0
|
|
51
|
+
)
|
|
52
|
+
self.rate = RateLimitState()
|
|
53
|
+
self._lock = asyncio.Lock()
|
|
54
|
+
|
|
55
|
+
async def get(
|
|
56
|
+
self,
|
|
57
|
+
path: str,
|
|
58
|
+
*,
|
|
59
|
+
params: QueryParamTypes = None,
|
|
60
|
+
) -> Any:
|
|
61
|
+
async with self._lock:
|
|
62
|
+
await self._respect_rate_limit()
|
|
63
|
+
|
|
64
|
+
resp = await self._client.get(path, params=params)
|
|
65
|
+
self._update_rate_limit(resp)
|
|
66
|
+
resp.raise_for_status()
|
|
67
|
+
|
|
68
|
+
return resp.json()
|
|
69
|
+
|
|
70
|
+
async def search(
|
|
71
|
+
self, name: str, project_type: ProjectType, loader: str, game_version: str
|
|
72
|
+
) -> Search:
|
|
73
|
+
facets = [
|
|
74
|
+
[f"project_type:{project_type}"],
|
|
75
|
+
[f"versions:{game_version}"],
|
|
76
|
+
]
|
|
77
|
+
params = {
|
|
78
|
+
"query": name,
|
|
79
|
+
"limit": 15,
|
|
80
|
+
"facets": json.dumps(facets),
|
|
81
|
+
}
|
|
82
|
+
if project_type == "mod":
|
|
83
|
+
facets.append([f"categories:{loader}"])
|
|
84
|
+
return Search.model_validate(await self.get("/search", params=params))
|
|
85
|
+
|
|
86
|
+
async def get_project(self, project_id: str) -> Hit:
|
|
87
|
+
endpoint = f"/project/{project_id}/"
|
|
88
|
+
return Hit.model_validate_json(await self.get(endpoint))
|
|
89
|
+
|
|
90
|
+
async def get_project_versions(self, project_id: str) -> list[ProjectVersion]:
|
|
91
|
+
endpoint = f"/project/{project_id}/version"
|
|
92
|
+
return ProjectVersions.validate_python(await self.get(endpoint))
|
|
93
|
+
|
|
94
|
+
# ------------------------
|
|
95
|
+
# rate limit logic
|
|
96
|
+
# ------------------------
|
|
97
|
+
def _update_rate_limit(self, resp: Response) -> None:
|
|
98
|
+
headers = resp.headers
|
|
99
|
+
|
|
100
|
+
self.rate.limit = self._safe_int(headers.get("X-Ratelimit-Limit"))
|
|
101
|
+
self.rate.remaining = self._safe_int(headers.get("X-Ratelimit-Remaining"))
|
|
102
|
+
|
|
103
|
+
reset = headers.get("X-Ratelimit-Reset")
|
|
104
|
+
if reset is not None:
|
|
105
|
+
self.rate.reset_after = self._safe_float(reset)
|
|
106
|
+
self.rate.last_update = time.time()
|
|
107
|
+
|
|
108
|
+
async def _respect_rate_limit(self) -> None:
|
|
109
|
+
if (
|
|
110
|
+
self.rate.remaining is not None
|
|
111
|
+
and self.rate.remaining <= 0
|
|
112
|
+
and self.rate.reset_after is not None
|
|
113
|
+
and self.rate.last_update is not None
|
|
114
|
+
):
|
|
115
|
+
wait_time = self.rate.reset_after - (time.time() - self.rate.last_update)
|
|
116
|
+
if wait_time > 0:
|
|
117
|
+
await asyncio.sleep(wait_time)
|
|
118
|
+
|
|
119
|
+
async def close(self) -> None:
|
|
120
|
+
await self._client.aclose()
|
|
121
|
+
|
|
122
|
+
async def __aexit__(self, *_: object) -> None:
|
|
123
|
+
await self.close()
|
|
124
|
+
|
|
125
|
+
# ------------------------
|
|
126
|
+
# Static methods
|
|
127
|
+
# ------------------------
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _safe_int(value: str | None) -> int | None:
|
|
130
|
+
try:
|
|
131
|
+
return int(value) if value is not None else None
|
|
132
|
+
except ValueError:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _safe_float(value: str | None) -> float | None:
|
|
137
|
+
try:
|
|
138
|
+
return float(value) if value is not None else None
|
|
139
|
+
except ValueError:
|
|
140
|
+
return None
|
packsmith/cli/add.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Copyright (c) 2026 Frank1o3
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import tomli_w
|
|
10
|
+
import typer
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from packsmith.api import ModrinthClient
|
|
14
|
+
from packsmith.cli.ui import console
|
|
15
|
+
from packsmith.core.models import (
|
|
16
|
+
ERROR_NOT_IN_PACK,
|
|
17
|
+
PROJECT_TYPE_TO_FIELD,
|
|
18
|
+
Hit,
|
|
19
|
+
LockFile,
|
|
20
|
+
LockPackage,
|
|
21
|
+
Manifest,
|
|
22
|
+
MatchResults,
|
|
23
|
+
MatchScore,
|
|
24
|
+
ProjectType,
|
|
25
|
+
Search,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from rich.console import Console
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def search(
|
|
33
|
+
name: str, project_type: ProjectType, loader: str, game_version: str
|
|
34
|
+
) -> Search:
|
|
35
|
+
client = ModrinthClient()
|
|
36
|
+
return await client.search(name, project_type, loader, game_version)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_lock(path: Path) -> LockFile:
|
|
40
|
+
if path.stat().st_size == 0:
|
|
41
|
+
return LockFile()
|
|
42
|
+
|
|
43
|
+
with path.open("rb") as f:
|
|
44
|
+
data = tomllib.load(f)
|
|
45
|
+
|
|
46
|
+
return LockFile.model_validate(data)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def save_lock(path: Path, lock: LockFile) -> None:
|
|
50
|
+
with path.open("wb") as f:
|
|
51
|
+
tomli_w.dump(lock.model_dump(exclude_none=True), f)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def save_manifest(path: Path, manifest: Manifest) -> None:
|
|
55
|
+
path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def has_package(lock: LockFile, project_id: str) -> bool:
|
|
59
|
+
return any(pkg.project_id == project_id for pkg in lock.packages)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def select_match(matched: MatchResults, console: Console) -> Hit:
|
|
63
|
+
if not matched.results:
|
|
64
|
+
console.print("[red]No matches found.[/red]")
|
|
65
|
+
raise typer.Exit(code=1)
|
|
66
|
+
|
|
67
|
+
table = Table(title="Select a mod", show_lines=False)
|
|
68
|
+
table.add_column("#", style="dim", width=4)
|
|
69
|
+
table.add_column("Name", style="cyan")
|
|
70
|
+
table.add_column("Slug", style="dim")
|
|
71
|
+
table.add_column("Confidence", justify="right")
|
|
72
|
+
|
|
73
|
+
for i, result in enumerate(matched.results, start=1):
|
|
74
|
+
hit = result.hit
|
|
75
|
+
score = result.score
|
|
76
|
+
|
|
77
|
+
if score >= MatchScore.ACRONYM:
|
|
78
|
+
style = "green"
|
|
79
|
+
elif score >= MatchScore.PREFIX:
|
|
80
|
+
style = "yellow"
|
|
81
|
+
else:
|
|
82
|
+
style = "red"
|
|
83
|
+
|
|
84
|
+
table.add_row(str(i), hit.title, hit.slug, f"[{style}]{score}[/]")
|
|
85
|
+
|
|
86
|
+
console.print(table)
|
|
87
|
+
|
|
88
|
+
choice = typer.prompt("Select a mod by number", type=int)
|
|
89
|
+
|
|
90
|
+
if choice < 1 or choice > len(matched.results):
|
|
91
|
+
console.print("[red]Invalid selection.[/red]")
|
|
92
|
+
raise typer.Exit(code=1)
|
|
93
|
+
|
|
94
|
+
return matched.results[choice - 1].hit
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def apply_add(
|
|
98
|
+
info: Manifest,
|
|
99
|
+
lock: LockFile,
|
|
100
|
+
hit: Hit,
|
|
101
|
+
) -> None:
|
|
102
|
+
field_name = PROJECT_TYPE_TO_FIELD.get(hit.project_type)
|
|
103
|
+
|
|
104
|
+
if field_name is None:
|
|
105
|
+
err = f"Unsupported project type: {hit.project_type}"
|
|
106
|
+
raise ValueError(err)
|
|
107
|
+
|
|
108
|
+
target_list: list[str] = getattr(info, field_name)
|
|
109
|
+
|
|
110
|
+
if hit.title not in target_list:
|
|
111
|
+
target_list.append(hit.title)
|
|
112
|
+
|
|
113
|
+
if not has_package(lock, hit.project_id):
|
|
114
|
+
lock.packages.append(
|
|
115
|
+
LockPackage(
|
|
116
|
+
project_id=hit.project_id,
|
|
117
|
+
project_type=hit.project_type,
|
|
118
|
+
client_side=hit.client_side,
|
|
119
|
+
server_side=hit.server_side,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def resolve_hit(matched: MatchResults, console: Console) -> Hit | None:
|
|
125
|
+
if not matched.results:
|
|
126
|
+
console.print("[red]No matches found.[/red]")
|
|
127
|
+
raise typer.Exit(code=1)
|
|
128
|
+
|
|
129
|
+
if matched.best and matched.best.score == MatchScore.EXACT:
|
|
130
|
+
return matched.best.hit
|
|
131
|
+
|
|
132
|
+
return select_match(matched, console)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def add(name: str, project_type: ProjectType) -> None:
|
|
136
|
+
path = Path.cwd()
|
|
137
|
+
register_file = path / "meta.json"
|
|
138
|
+
lock_file = path / "lock.toml"
|
|
139
|
+
|
|
140
|
+
if not register_file.exists() or not lock_file.exists():
|
|
141
|
+
raise RuntimeError(ERROR_NOT_IN_PACK)
|
|
142
|
+
|
|
143
|
+
info = Manifest.model_validate_json(register_file.read_text("utf-8"))
|
|
144
|
+
lock = load_lock(lock_file)
|
|
145
|
+
|
|
146
|
+
search_res = asyncio.run(search(name, project_type, info.loader, info.game_version))
|
|
147
|
+
matched = search_res.match(name, project_type)
|
|
148
|
+
|
|
149
|
+
hit = resolve_hit(matched, console)
|
|
150
|
+
|
|
151
|
+
if hit is None:
|
|
152
|
+
raise typer.Exit
|
|
153
|
+
|
|
154
|
+
apply_add(info, lock, hit)
|
|
155
|
+
|
|
156
|
+
save_manifest(register_file, info)
|
|
157
|
+
save_lock(lock_file, lock)
|
|
158
|
+
console.log(
|
|
159
|
+
f"[green]Added[/green] {hit.title} ({hit.project_type}) to the modpack."
|
|
160
|
+
)
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# Copyright (c) 2026 Frank1o3
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import tomllib
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import unquote, urlparse
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import typer
|
|
14
|
+
from rich.progress import (
|
|
15
|
+
BarColumn,
|
|
16
|
+
DownloadColumn,
|
|
17
|
+
Progress,
|
|
18
|
+
TextColumn,
|
|
19
|
+
TimeRemainingColumn,
|
|
20
|
+
TransferSpeedColumn,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from packsmith.cli.ui import console
|
|
24
|
+
from packsmith.core.models import Hashes, LockFile, LockPackage
|
|
25
|
+
|
|
26
|
+
user_agent = (
|
|
27
|
+
f"Frank1o3/packsmith/{version('packsmith')} (https://github.com/Frank1o3/packsmith)"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_lock(path: Path) -> LockFile:
|
|
32
|
+
if path.stat().st_size == 0:
|
|
33
|
+
return LockFile()
|
|
34
|
+
|
|
35
|
+
with path.open("rb") as f:
|
|
36
|
+
data = tomllib.load(f)
|
|
37
|
+
|
|
38
|
+
return LockFile.model_validate(data)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_filename_from_url(url: str, *, fallback: str | None = None) -> str:
|
|
42
|
+
if fallback:
|
|
43
|
+
return fallback
|
|
44
|
+
|
|
45
|
+
name = Path(unquote(urlparse(url).path)).name
|
|
46
|
+
if not name:
|
|
47
|
+
name = unquote(url.rsplit("/", maxsplit=1)[-1])
|
|
48
|
+
return name or "downloaded.file"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _get_package_dir(
|
|
52
|
+
package: LockPackage,
|
|
53
|
+
mods_dir: Path,
|
|
54
|
+
resourcepacks_dir: Path,
|
|
55
|
+
shaders_dir: Path,
|
|
56
|
+
) -> Path:
|
|
57
|
+
if package.project_type == "resourcepack":
|
|
58
|
+
return resourcepacks_dir
|
|
59
|
+
if package.project_type == "shader":
|
|
60
|
+
return shaders_dir
|
|
61
|
+
return mods_dir
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _validate_hashes(data: bytes, hashes: Hashes) -> bool:
|
|
65
|
+
if hashes.sha512:
|
|
66
|
+
actual_sha512 = hashlib.sha512(data).hexdigest()
|
|
67
|
+
if not hmac.compare_digest(actual_sha512, hashes.sha512):
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
if hashes.sha1:
|
|
71
|
+
actual_sha1 = hashlib.new("sha1", data).hexdigest()
|
|
72
|
+
if not hmac.compare_digest(actual_sha1, hashes.sha1):
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def _ensure_dir(path: Path) -> None:
|
|
79
|
+
await asyncio.to_thread(path.mkdir, parents=True, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _read_file_bytes(path: Path) -> bytes:
|
|
83
|
+
return await asyncio.to_thread(path.read_bytes)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def _write_file_bytes(path: Path, data: bytes) -> None:
|
|
87
|
+
await asyncio.to_thread(path.write_bytes, data)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _download_package(
|
|
91
|
+
client: httpx.AsyncClient,
|
|
92
|
+
package: LockPackage,
|
|
93
|
+
target_dir: Path,
|
|
94
|
+
progress: Progress,
|
|
95
|
+
semaphore: asyncio.Semaphore,
|
|
96
|
+
) -> tuple[LockPackage, Path, str | None]:
|
|
97
|
+
if package.file is None:
|
|
98
|
+
message = f"Package {package.project_id} has no download file."
|
|
99
|
+
raise RuntimeError(message)
|
|
100
|
+
|
|
101
|
+
file = package.file
|
|
102
|
+
filename = _get_filename_from_url(file.url, fallback=file.filename)
|
|
103
|
+
target_path = target_dir / filename
|
|
104
|
+
await _ensure_dir(target_dir)
|
|
105
|
+
|
|
106
|
+
if target_path.exists():
|
|
107
|
+
existing_data = await _read_file_bytes(target_path)
|
|
108
|
+
if _validate_hashes(existing_data, file.hashes):
|
|
109
|
+
return package, target_path, "already downloaded"
|
|
110
|
+
|
|
111
|
+
task_id = progress.add_task(
|
|
112
|
+
"download",
|
|
113
|
+
filename=filename,
|
|
114
|
+
total=file.size or 0,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
async with semaphore, client.stream("GET", file.url) as response:
|
|
118
|
+
response.raise_for_status()
|
|
119
|
+
total = int(response.headers.get("Content-Length", file.size or 0))
|
|
120
|
+
progress.update(task_id, total=total)
|
|
121
|
+
data = bytearray()
|
|
122
|
+
|
|
123
|
+
async for chunk in response.aiter_bytes(65536):
|
|
124
|
+
if not chunk:
|
|
125
|
+
continue
|
|
126
|
+
data.extend(chunk)
|
|
127
|
+
progress.update(task_id, advance=len(chunk))
|
|
128
|
+
|
|
129
|
+
final_data = bytes(data)
|
|
130
|
+
await _write_file_bytes(target_path, final_data)
|
|
131
|
+
progress.update(task_id, completed=total)
|
|
132
|
+
|
|
133
|
+
if not _validate_hashes(final_data, file.hashes):
|
|
134
|
+
target_path.unlink(missing_ok=True)
|
|
135
|
+
message = f"Hash mismatch for {package.project_id} ({filename})."
|
|
136
|
+
raise RuntimeError(message)
|
|
137
|
+
|
|
138
|
+
return package, target_path, None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def _download_all(
|
|
142
|
+
lock: LockFile,
|
|
143
|
+
mods_dir: Path,
|
|
144
|
+
resourcepacks_dir: Path,
|
|
145
|
+
shaders_dir: Path,
|
|
146
|
+
) -> None:
|
|
147
|
+
packages = [
|
|
148
|
+
pkg for pkg in lock.packages if pkg.state == "resolved" and pkg.file is not None
|
|
149
|
+
]
|
|
150
|
+
if not packages:
|
|
151
|
+
console.print(
|
|
152
|
+
"[yellow]No resolved packages with download files found.[/yellow]"
|
|
153
|
+
)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
timeout = httpx.Timeout(60.0, connect=15.0)
|
|
157
|
+
semaphore = asyncio.Semaphore(5)
|
|
158
|
+
async with httpx.AsyncClient(
|
|
159
|
+
headers={"User-Agent": user_agent}, timeout=timeout
|
|
160
|
+
) as client:
|
|
161
|
+
with Progress(
|
|
162
|
+
TextColumn("{task.fields[filename]}", justify="left"),
|
|
163
|
+
BarColumn(),
|
|
164
|
+
DownloadColumn(),
|
|
165
|
+
TransferSpeedColumn(),
|
|
166
|
+
TimeRemainingColumn(),
|
|
167
|
+
console=console,
|
|
168
|
+
transient=True,
|
|
169
|
+
) as progress:
|
|
170
|
+
tasks = []
|
|
171
|
+
for package in packages:
|
|
172
|
+
target_dir = _get_package_dir(
|
|
173
|
+
package, mods_dir, resourcepacks_dir, shaders_dir
|
|
174
|
+
)
|
|
175
|
+
tasks.append(
|
|
176
|
+
_download_package(client, package, target_dir, progress, semaphore)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
180
|
+
|
|
181
|
+
failures = 0
|
|
182
|
+
for result in results:
|
|
183
|
+
if isinstance(result, BaseException):
|
|
184
|
+
failures += 1
|
|
185
|
+
console.print(f"[red]Download failed:[/red] {result}")
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
package, path, message = result
|
|
189
|
+
if message:
|
|
190
|
+
console.print(f"[cyan]Skipped existing file:[/cyan] {path.name}")
|
|
191
|
+
else:
|
|
192
|
+
console.print(f"[green]Downloaded:[/green] {path.name}")
|
|
193
|
+
|
|
194
|
+
if failures:
|
|
195
|
+
message = f"{failures} file(s) failed to download."
|
|
196
|
+
raise RuntimeError(message)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def download() -> None:
|
|
200
|
+
"""Download all dependencies for the current modpack.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
Exit: If the download fails or the project is invalid.
|
|
204
|
+
|
|
205
|
+
"""
|
|
206
|
+
path = Path.cwd()
|
|
207
|
+
register_file = path / "meta.json"
|
|
208
|
+
lock_file = path / "lock.toml"
|
|
209
|
+
mods_dir = path / "mods"
|
|
210
|
+
resourcepacks_dir = path / "overrides" / "resourcepacks"
|
|
211
|
+
shaders_dir = path / "overrides" / "shaderpacks"
|
|
212
|
+
|
|
213
|
+
if not register_file.exists():
|
|
214
|
+
console.print(
|
|
215
|
+
"[red]Error:[/red] Not inside a Packsmith project (meta.json not found)."
|
|
216
|
+
)
|
|
217
|
+
raise typer.Exit(code=1)
|
|
218
|
+
|
|
219
|
+
if not lock_file.exists():
|
|
220
|
+
console.print("[red]Error:[/red] Missing lock file.")
|
|
221
|
+
raise typer.Exit(code=1)
|
|
222
|
+
|
|
223
|
+
lock = load_lock(lock_file)
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
asyncio.run(_download_all(lock, mods_dir, resourcepacks_dir, shaders_dir))
|
|
227
|
+
except Exception as exc:
|
|
228
|
+
console.print(f"[red]Download failed:[/red] {exc}")
|
|
229
|
+
raise typer.Exit(code=1) from exc
|