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
modsmith/instances.py
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
"""Game directories managed from a modpack.
|
|
2
|
+
|
|
3
|
+
`Instance.sync` is both install and update: it brings a directory to the state
|
|
4
|
+
a pack describes, tracks what it wrote, and leaves the user's changes alone.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import time
|
|
10
|
+
import zipfile
|
|
11
|
+
from collections.abc import Iterable, Mapping
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ._support import replace_file, write_atomic, write_json
|
|
18
|
+
from .errors import InstanceError, UnresolvedEntriesError
|
|
19
|
+
from .net import Session, download, file_hash
|
|
20
|
+
from .net._resource import matches, stream_hash, strongest_hash
|
|
21
|
+
from .net._session import session_scope
|
|
22
|
+
from .packs import Modpack, PackEntry, Side
|
|
23
|
+
from .progress import CancelToken, ProgressCallback, report
|
|
24
|
+
|
|
25
|
+
__all__ = ["Changes", "Instance", "InstanceState"]
|
|
26
|
+
|
|
27
|
+
STATE_FILE = ".modsmith/instance.json"
|
|
28
|
+
STAGING_DIR = ".modsmith/staging"
|
|
29
|
+
BACKUP_DIR = ".modsmith/backup"
|
|
30
|
+
HASH_CACHE = ".modsmith/hashes.json"
|
|
31
|
+
CACHE_SETTLE_NS = 2_000_000_000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class InstanceState:
|
|
36
|
+
"""What the last sync installed.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
pack_name: The pack's name.
|
|
40
|
+
pack_version: The pack's version.
|
|
41
|
+
minecraft: The Minecraft version.
|
|
42
|
+
loader_kind: The mod loader's name.
|
|
43
|
+
loader_version: The mod loader's version.
|
|
44
|
+
files: The SHA-1 of each file the pack installed, keyed by path.
|
|
45
|
+
kept: The paths where the player's version was kept.
|
|
46
|
+
synced_at: When the sync finished, in ISO 8601 format.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
pack_name: str
|
|
50
|
+
pack_version: str
|
|
51
|
+
minecraft: str
|
|
52
|
+
loader_kind: str
|
|
53
|
+
loader_version: str
|
|
54
|
+
files: dict[str, str] = field(default_factory=dict[str, str])
|
|
55
|
+
kept: tuple[str, ...] = ()
|
|
56
|
+
synced_at: str = ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Changes:
|
|
61
|
+
"""What a sync would do, or did. Each field holds paths relative to the instance.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
added: Files the pack adds.
|
|
65
|
+
updated: Files the pack replaces.
|
|
66
|
+
removed: Files the pack no longer has.
|
|
67
|
+
unchanged: Files that already match.
|
|
68
|
+
conflicts: Files the pack changes that the player also changed. They are
|
|
69
|
+
kept unless ``overwrite`` is set.
|
|
70
|
+
orphaned: Files the pack no longer has that the player changed. They are
|
|
71
|
+
kept.
|
|
72
|
+
unresolved: Pack entries that have no download.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
added: tuple[str, ...] = ()
|
|
76
|
+
updated: tuple[str, ...] = ()
|
|
77
|
+
removed: tuple[str, ...] = ()
|
|
78
|
+
unchanged: tuple[str, ...] = ()
|
|
79
|
+
conflicts: tuple[str, ...] = ()
|
|
80
|
+
orphaned: tuple[str, ...] = ()
|
|
81
|
+
unresolved: tuple[PackEntry, ...] = ()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class _Target:
|
|
86
|
+
"""What one path should contain after a sync."""
|
|
87
|
+
|
|
88
|
+
hashes: Mapping[str, str]
|
|
89
|
+
entry: PackEntry | None = None
|
|
90
|
+
override: bool = False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _targets(
|
|
94
|
+
pack: Modpack, side: Side, include_optional: bool, cache: "_HashCache"
|
|
95
|
+
) -> tuple[dict[str, _Target], tuple[PackEntry, ...]]:
|
|
96
|
+
"""Return what a pack wants on disk, and its unresolved entries."""
|
|
97
|
+
targets: dict[str, _Target] = {}
|
|
98
|
+
unresolved: list[PackEntry] = []
|
|
99
|
+
for entry in pack.entries:
|
|
100
|
+
if not entry.wanted(side, include_optional):
|
|
101
|
+
continue
|
|
102
|
+
if not entry.is_resolved:
|
|
103
|
+
unresolved.append(entry)
|
|
104
|
+
if entry.path:
|
|
105
|
+
targets[entry.path] = _Target(entry.hashes, entry=entry)
|
|
106
|
+
members = pack.override_members(side)
|
|
107
|
+
unknown = {
|
|
108
|
+
relative: info
|
|
109
|
+
for relative, info in members.items()
|
|
110
|
+
if cache.override_sha1(relative, info) is None
|
|
111
|
+
}
|
|
112
|
+
for relative, file in pack.iter_override_files(side, unknown):
|
|
113
|
+
cache.remember_override(relative, unknown[relative], stream_hash(file))
|
|
114
|
+
for relative, info in members.items():
|
|
115
|
+
digest = cache.override_sha1(relative, info)
|
|
116
|
+
targets[relative] = _Target({"sha1": digest or ""}, override=True)
|
|
117
|
+
return targets, tuple(unresolved)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _HashCache:
|
|
121
|
+
"""Hashes of an instance's files, so unchanged files are not read again.
|
|
122
|
+
|
|
123
|
+
A file counts as unchanged while its size and modification time are the
|
|
124
|
+
same. Files changed moments before saving are left out, because another
|
|
125
|
+
change could land on the same timestamp.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, root: Path) -> None:
|
|
129
|
+
self.root = root
|
|
130
|
+
self.path = root / HASH_CACHE
|
|
131
|
+
self.files: dict[str, dict[str, Any]] = {}
|
|
132
|
+
self.overrides: dict[str, list[Any]] = {}
|
|
133
|
+
try:
|
|
134
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
135
|
+
self.files = dict(data["files"])
|
|
136
|
+
self.overrides = dict(data["overrides"])
|
|
137
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
138
|
+
self.files = {}
|
|
139
|
+
self.overrides = {}
|
|
140
|
+
|
|
141
|
+
def digest(self, relative: str, algorithm: str = "sha1") -> str:
|
|
142
|
+
"""Return a file's hash, reading the file only if it has changed."""
|
|
143
|
+
path = self.root / relative
|
|
144
|
+
stat = path.stat()
|
|
145
|
+
entry: dict[str, Any] | None = self.files.get(relative)
|
|
146
|
+
if (
|
|
147
|
+
entry is None
|
|
148
|
+
or entry.get("size") != stat.st_size
|
|
149
|
+
or entry.get("mtime_ns") != stat.st_mtime_ns
|
|
150
|
+
):
|
|
151
|
+
entry = {"size": stat.st_size, "mtime_ns": stat.st_mtime_ns}
|
|
152
|
+
self.files[relative] = entry
|
|
153
|
+
if algorithm not in entry:
|
|
154
|
+
entry[algorithm] = file_hash(path, algorithm)
|
|
155
|
+
return entry[algorithm]
|
|
156
|
+
|
|
157
|
+
def matches(self, relative: str, hashes: Mapping[str, str]) -> bool:
|
|
158
|
+
"""Check a file against its strongest hash."""
|
|
159
|
+
expected = strongest_hash(hashes)
|
|
160
|
+
return expected is None or self.digest(relative, expected[0]) == expected[1]
|
|
161
|
+
|
|
162
|
+
def override_sha1(self, relative: str, info: zipfile.ZipInfo) -> str | None:
|
|
163
|
+
"""Return the known hash of an override, or None if it has changed."""
|
|
164
|
+
entry = self.overrides.get(relative)
|
|
165
|
+
if entry is None or entry[:2] != [info.CRC, info.file_size]:
|
|
166
|
+
return None
|
|
167
|
+
return entry[2]
|
|
168
|
+
|
|
169
|
+
def remember_override(
|
|
170
|
+
self, relative: str, info: zipfile.ZipInfo, digest: str
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Record the hash of an override."""
|
|
173
|
+
self.overrides[relative] = [info.CRC, info.file_size, digest]
|
|
174
|
+
|
|
175
|
+
def save(self) -> None:
|
|
176
|
+
"""Write the cache, leaving out files that changed moments ago."""
|
|
177
|
+
settled = time.time_ns() - CACHE_SETTLE_NS
|
|
178
|
+
files = {
|
|
179
|
+
relative: entry
|
|
180
|
+
for relative, entry in self.files.items()
|
|
181
|
+
if entry["mtime_ns"] < settled
|
|
182
|
+
}
|
|
183
|
+
write_json(self.path, {"files": files, "overrides": self.overrides})
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _find_adoptable(
|
|
187
|
+
entries: Iterable[PackEntry], folders: list[Path]
|
|
188
|
+
) -> dict[str, Path]:
|
|
189
|
+
"""Find manually downloaded files by name, keeping those with the right hash."""
|
|
190
|
+
found: dict[str, Path] = {}
|
|
191
|
+
for entry in entries:
|
|
192
|
+
if entry.path is None:
|
|
193
|
+
continue
|
|
194
|
+
name = Path(entry.path).name
|
|
195
|
+
for folder in folders:
|
|
196
|
+
candidate = folder / name
|
|
197
|
+
if candidate.is_file() and matches(candidate, entry.hashes):
|
|
198
|
+
found[entry.path] = candidate
|
|
199
|
+
break
|
|
200
|
+
return found
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _move(source: Path, dest: Path) -> None:
|
|
204
|
+
"""Move a file, creating the destination's parent directories."""
|
|
205
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
replace_file(source, dest)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class Instance:
|
|
210
|
+
"""A game directory whose contents come from a modpack.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
path: The directory. It is created by the first sync.
|
|
214
|
+
|
|
215
|
+
Attributes:
|
|
216
|
+
path: The directory.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def __init__(self, path: Path | str) -> None:
|
|
220
|
+
self.path = Path(path)
|
|
221
|
+
|
|
222
|
+
@classmethod
|
|
223
|
+
def open(cls, path: Path | str) -> "Instance":
|
|
224
|
+
"""Return the instance at ``path``, which does not have to exist yet."""
|
|
225
|
+
return cls(path)
|
|
226
|
+
|
|
227
|
+
def __repr__(self) -> str:
|
|
228
|
+
return f"Instance({str(self.path)!r})"
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def _state_path(self) -> Path:
|
|
232
|
+
"""The file that records the last sync."""
|
|
233
|
+
return self.path / STATE_FILE
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def _staging_dir(self) -> Path:
|
|
237
|
+
"""Where new files wait until every one of them is ready."""
|
|
238
|
+
return self.path / STAGING_DIR
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def _backup_dir(self) -> Path:
|
|
242
|
+
"""Where replaced files wait until a commit has finished."""
|
|
243
|
+
return self.path / BACKUP_DIR
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def state(self) -> InstanceState | None:
|
|
247
|
+
"""What the last sync installed, or None if nothing has been synced here.
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
InstanceError: The state file is corrupt.
|
|
251
|
+
"""
|
|
252
|
+
path = self._state_path
|
|
253
|
+
if not path.is_file():
|
|
254
|
+
return None
|
|
255
|
+
try:
|
|
256
|
+
data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
257
|
+
data["kept"] = tuple(data.get("kept", ()))
|
|
258
|
+
return InstanceState(**data)
|
|
259
|
+
except (ValueError, TypeError) as e:
|
|
260
|
+
raise InstanceError(f"Corrupt instance state in {self.path}: {e}") from e
|
|
261
|
+
|
|
262
|
+
def diff(
|
|
263
|
+
self, pack: Modpack, *, side: Side = "client", include_optional: bool = False
|
|
264
|
+
) -> Changes:
|
|
265
|
+
"""Compare this directory with a pack. Changes nothing.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
pack: The modpack.
|
|
269
|
+
side: The side to install.
|
|
270
|
+
include_optional: Include the pack's optional files.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
What a sync would do.
|
|
274
|
+
|
|
275
|
+
Raises:
|
|
276
|
+
InstanceError: The state file is corrupt.
|
|
277
|
+
"""
|
|
278
|
+
cache = _HashCache(self.path)
|
|
279
|
+
targets, unresolved = _targets(pack, side, include_optional, cache)
|
|
280
|
+
return self._classify(targets, unresolved, cache)
|
|
281
|
+
|
|
282
|
+
def _classify(
|
|
283
|
+
self,
|
|
284
|
+
targets: dict[str, _Target],
|
|
285
|
+
unresolved: tuple[PackEntry, ...],
|
|
286
|
+
cache: _HashCache,
|
|
287
|
+
) -> Changes:
|
|
288
|
+
"""Sort each target and previously installed file into `Changes`."""
|
|
289
|
+
state = self.state
|
|
290
|
+
managed = state.files if state else {}
|
|
291
|
+
added: list[str] = []
|
|
292
|
+
updated: list[str] = []
|
|
293
|
+
unchanged: list[str] = []
|
|
294
|
+
conflicts: list[str] = []
|
|
295
|
+
for relative, target in targets.items():
|
|
296
|
+
dest = self.path / relative
|
|
297
|
+
if not dest.is_file():
|
|
298
|
+
added.append(relative)
|
|
299
|
+
elif cache.matches(relative, target.hashes):
|
|
300
|
+
unchanged.append(relative)
|
|
301
|
+
# With no state, or a file as we left it, the player did not edit it.
|
|
302
|
+
elif state is None or cache.digest(relative) == managed.get(relative):
|
|
303
|
+
updated.append(relative)
|
|
304
|
+
else:
|
|
305
|
+
conflicts.append(relative)
|
|
306
|
+
|
|
307
|
+
removed: list[str] = []
|
|
308
|
+
orphaned: list[str] = []
|
|
309
|
+
for relative, expected in managed.items():
|
|
310
|
+
dest = self.path / relative
|
|
311
|
+
if relative in targets or not dest.is_file():
|
|
312
|
+
continue
|
|
313
|
+
unedited = cache.digest(relative) == expected
|
|
314
|
+
(removed if unedited else orphaned).append(relative)
|
|
315
|
+
return Changes(
|
|
316
|
+
tuple(added),
|
|
317
|
+
tuple(updated),
|
|
318
|
+
tuple(removed),
|
|
319
|
+
tuple(unchanged),
|
|
320
|
+
tuple(conflicts),
|
|
321
|
+
tuple(orphaned),
|
|
322
|
+
unresolved,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def _stage(
|
|
326
|
+
self,
|
|
327
|
+
pack: Modpack,
|
|
328
|
+
side: Side,
|
|
329
|
+
targets: dict[str, _Target],
|
|
330
|
+
pending: list[str],
|
|
331
|
+
adopted: dict[str, Path],
|
|
332
|
+
*,
|
|
333
|
+
session: Session,
|
|
334
|
+
max_workers: int,
|
|
335
|
+
progress: ProgressCallback | None,
|
|
336
|
+
cancel: CancelToken | None,
|
|
337
|
+
) -> list[str]:
|
|
338
|
+
"""Put new files in the staging directory and return their paths."""
|
|
339
|
+
staging = self._staging_dir
|
|
340
|
+
entries = {
|
|
341
|
+
relative: target.entry
|
|
342
|
+
for relative in pending
|
|
343
|
+
if (target := targets[relative]).entry and target.entry.is_resolved
|
|
344
|
+
}
|
|
345
|
+
download(
|
|
346
|
+
[entry.resource(staging) for entry in entries.values()],
|
|
347
|
+
session=session,
|
|
348
|
+
max_workers=max_workers,
|
|
349
|
+
progress=progress,
|
|
350
|
+
cancel=cancel,
|
|
351
|
+
task="files",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
writes = [item for item in pending if targets[item].override]
|
|
355
|
+
files = pack.iter_override_files(side, set(writes))
|
|
356
|
+
for index, (relative, file) in enumerate(files):
|
|
357
|
+
if cancel is not None:
|
|
358
|
+
cancel.check()
|
|
359
|
+
write_atomic(staging / relative, file)
|
|
360
|
+
report(progress, "overrides", index + 1, len(writes), "file")
|
|
361
|
+
|
|
362
|
+
copies = [item for item in pending if item in adopted and item not in writes]
|
|
363
|
+
for relative in copies:
|
|
364
|
+
(staging / relative).parent.mkdir(parents=True, exist_ok=True)
|
|
365
|
+
shutil.copy2(adopted[relative], staging / relative)
|
|
366
|
+
return [*entries, *writes, *copies]
|
|
367
|
+
|
|
368
|
+
def _record(
|
|
369
|
+
self,
|
|
370
|
+
pack: Modpack,
|
|
371
|
+
targets: dict[str, _Target],
|
|
372
|
+
changes: Changes,
|
|
373
|
+
overwrite: bool,
|
|
374
|
+
cache: _HashCache,
|
|
375
|
+
) -> None:
|
|
376
|
+
"""Save what this sync installed."""
|
|
377
|
+
previous = self.state.files if self.state else {}
|
|
378
|
+
# Kept files record the pack's hash, so they stay conflicts on the next sync.
|
|
379
|
+
kept: set[str] = set() if overwrite else set(changes.conflicts)
|
|
380
|
+
files: dict[str, str] = {}
|
|
381
|
+
for relative, target in targets.items():
|
|
382
|
+
if relative in kept:
|
|
383
|
+
files[relative] = target.hashes.get("sha1") or previous.get(
|
|
384
|
+
relative, ""
|
|
385
|
+
)
|
|
386
|
+
else:
|
|
387
|
+
files[relative] = cache.digest(relative)
|
|
388
|
+
state = InstanceState(
|
|
389
|
+
pack_name=pack.name,
|
|
390
|
+
pack_version=pack.version,
|
|
391
|
+
minecraft=pack.minecraft,
|
|
392
|
+
loader_kind=pack.loader_kind,
|
|
393
|
+
loader_version=pack.loader_version,
|
|
394
|
+
files=files,
|
|
395
|
+
kept=tuple(sorted(kept)),
|
|
396
|
+
synced_at=datetime.now(UTC).isoformat(timespec="seconds"),
|
|
397
|
+
)
|
|
398
|
+
write_json(self._state_path, asdict(state))
|
|
399
|
+
|
|
400
|
+
def sync(
|
|
401
|
+
self,
|
|
402
|
+
pack: Modpack,
|
|
403
|
+
*,
|
|
404
|
+
side: Side = "client",
|
|
405
|
+
include_optional: bool = False,
|
|
406
|
+
overwrite: bool = False,
|
|
407
|
+
adopt_from: Iterable[Path | str] = (),
|
|
408
|
+
session: Session | None = None,
|
|
409
|
+
max_workers: int = 8,
|
|
410
|
+
progress: ProgressCallback | None = None,
|
|
411
|
+
cancel: CancelToken | None = None,
|
|
412
|
+
) -> Changes:
|
|
413
|
+
"""Make this directory match a pack and return what changed.
|
|
414
|
+
|
|
415
|
+
Install and update are the same call. New files are downloaded to a
|
|
416
|
+
staging directory first, so the instance is only touched once everything
|
|
417
|
+
is ready. An interrupted sync can simply be run again, and it reuses what
|
|
418
|
+
was already downloaded. A file whose size and modification time have not
|
|
419
|
+
changed since the last sync is assumed to have the same contents.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
pack: The modpack.
|
|
423
|
+
side: The side to install.
|
|
424
|
+
include_optional: Include the pack's optional files.
|
|
425
|
+
overwrite: Replace the player's changes instead of keeping them.
|
|
426
|
+
adopt_from: Folders to search for entries that cannot be downloaded.
|
|
427
|
+
Files are found by name and verified.
|
|
428
|
+
session: The session to use. A temporary one is created if omitted.
|
|
429
|
+
max_workers: How many files to download at once.
|
|
430
|
+
progress: Receives updates for each stage.
|
|
431
|
+
cancel: Stops the sync. It has no effect once files are being moved
|
|
432
|
+
into the instance.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
What changed.
|
|
436
|
+
|
|
437
|
+
Raises:
|
|
438
|
+
UnresolvedEntriesError: Entries are still missing. Nothing has been
|
|
439
|
+
changed.
|
|
440
|
+
InstanceError: The state file is corrupt, or a file could not be
|
|
441
|
+
replaced, usually because the game is running. Everything is
|
|
442
|
+
put back as it was.
|
|
443
|
+
PackError: The pack's files are unsafe.
|
|
444
|
+
DownloadError: A file could not be downloaded.
|
|
445
|
+
Cancelled: ``cancel`` was triggered.
|
|
446
|
+
"""
|
|
447
|
+
self._restore_backup()
|
|
448
|
+
cache = _HashCache(self.path)
|
|
449
|
+
targets, unresolved = _targets(pack, side, include_optional, cache)
|
|
450
|
+
folders = [Path(folder) for folder in adopt_from]
|
|
451
|
+
adopted = _find_adoptable(unresolved, folders)
|
|
452
|
+
missing = [
|
|
453
|
+
entry
|
|
454
|
+
for entry in unresolved
|
|
455
|
+
if entry.path is None
|
|
456
|
+
or not (entry.path in adopted or (self.path / entry.path).is_file())
|
|
457
|
+
]
|
|
458
|
+
if missing:
|
|
459
|
+
raise UnresolvedEntriesError(missing)
|
|
460
|
+
|
|
461
|
+
changes = self._classify(targets, unresolved, cache)
|
|
462
|
+
pending = [*changes.added, *changes.updated]
|
|
463
|
+
if overwrite:
|
|
464
|
+
pending.extend(changes.conflicts)
|
|
465
|
+
with session_scope(session) as client:
|
|
466
|
+
staged = self._stage(
|
|
467
|
+
pack,
|
|
468
|
+
side,
|
|
469
|
+
targets,
|
|
470
|
+
pending,
|
|
471
|
+
adopted,
|
|
472
|
+
session=client,
|
|
473
|
+
max_workers=max_workers,
|
|
474
|
+
progress=progress,
|
|
475
|
+
cancel=cancel,
|
|
476
|
+
)
|
|
477
|
+
if cancel is not None:
|
|
478
|
+
cancel.check()
|
|
479
|
+
self._commit(staged, changes.removed)
|
|
480
|
+
self._record(pack, targets, changes, overwrite, cache)
|
|
481
|
+
cache.save()
|
|
482
|
+
return changes
|
|
483
|
+
|
|
484
|
+
def _commit(self, staged: list[str], removed: tuple[str, ...]) -> None:
|
|
485
|
+
"""Move staged files into place, undoing everything if a move fails."""
|
|
486
|
+
backup = self._backup_dir
|
|
487
|
+
staging = self._staging_dir
|
|
488
|
+
placed: list[str] = []
|
|
489
|
+
try:
|
|
490
|
+
for relative in [*staged, *removed]:
|
|
491
|
+
if (self.path / relative).is_file():
|
|
492
|
+
_move(self.path / relative, backup / relative)
|
|
493
|
+
for relative in staged:
|
|
494
|
+
_move(staging / relative, self.path / relative)
|
|
495
|
+
placed.append(relative)
|
|
496
|
+
except OSError as e:
|
|
497
|
+
for relative in placed:
|
|
498
|
+
_move(self.path / relative, staging / relative)
|
|
499
|
+
self._restore_backup()
|
|
500
|
+
raise InstanceError(
|
|
501
|
+
f"Cannot update {self.path}, so it was left as it was. "
|
|
502
|
+
f"Check that the game is closed: {e}"
|
|
503
|
+
) from e
|
|
504
|
+
shutil.rmtree(backup, ignore_errors=True)
|
|
505
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
506
|
+
|
|
507
|
+
def _restore_backup(self) -> None:
|
|
508
|
+
"""Put back files from a commit that did not finish."""
|
|
509
|
+
backup = self._backup_dir
|
|
510
|
+
if not backup.is_dir():
|
|
511
|
+
return
|
|
512
|
+
for path in [item for item in backup.rglob("*") if item.is_file()]:
|
|
513
|
+
_move(path, self.path / path.relative_to(backup))
|
|
514
|
+
shutil.rmtree(backup, ignore_errors=True)
|
|
515
|
+
|
|
516
|
+
def delete(self) -> None:
|
|
517
|
+
"""Delete this instance's directory. Refuses unmanaged directories.
|
|
518
|
+
|
|
519
|
+
Raises:
|
|
520
|
+
InstanceError: The directory has no state from a previous sync.
|
|
521
|
+
"""
|
|
522
|
+
if self.state is None:
|
|
523
|
+
raise InstanceError(f"{self.path} is not a modsmith instance")
|
|
524
|
+
shutil.rmtree(self.path)
|