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 ADDED
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2026 Frank1o3
2
+ # SPDX-License-Identifier: MIT
packsmith/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ # Copyright (c) 2026 Frank1o3
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from .main import main
5
+
6
+ main()
@@ -0,0 +1,5 @@
1
+ # Copyright (c) 2026 Frank1o3
2
+ # SPDX-License-Identifier: MIT
3
+ from .modrinth import ModrinthClient
4
+
5
+ __all__ = ["ModrinthClient"]
@@ -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
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2026 Frank1o3
2
+ # SPDX-License-Identifier: MIT
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