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/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)