dao-modman 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.
Files changed (42) hide show
  1. dao_modman/__init__.py +0 -0
  2. dao_modman/__main__.py +252 -0
  3. dao_modman/assets/presets.toml +28 -0
  4. dao_modman/core/__init__.py +0 -0
  5. dao_modman/core/addins.py +120 -0
  6. dao_modman/core/archive.py +129 -0
  7. dao_modman/core/chargen.py +186 -0
  8. dao_modman/core/config.py +100 -0
  9. dao_modman/core/console.py +126 -0
  10. dao_modman/core/dazip.py +75 -0
  11. dao_modman/core/deploy.py +543 -0
  12. dao_modman/core/erf.py +83 -0
  13. dao_modman/core/gff.py +174 -0
  14. dao_modman/core/ignore.py +64 -0
  15. dao_modman/core/install.py +97 -0
  16. dao_modman/core/ipc.py +119 -0
  17. dao_modman/core/itemlists.py +175 -0
  18. dao_modman/core/laa.py +102 -0
  19. dao_modman/core/launch.py +33 -0
  20. dao_modman/core/library.py +338 -0
  21. dao_modman/core/metadata.py +176 -0
  22. dao_modman/core/models.py +112 -0
  23. dao_modman/core/moditems.py +205 -0
  24. dao_modman/core/nexus.py +244 -0
  25. dao_modman/core/overridepkg.py +192 -0
  26. dao_modman/core/pathops.py +151 -0
  27. dao_modman/core/paths.py +151 -0
  28. dao_modman/core/presets.py +109 -0
  29. dao_modman/core/report.py +210 -0
  30. dao_modman/core/resources.py +79 -0
  31. dao_modman/core/revisions.py +69 -0
  32. dao_modman/core/terminal.py +61 -0
  33. dao_modman/core/textures.py +148 -0
  34. dao_modman/tui/__init__.py +0 -0
  35. dao_modman/tui/app.py +1049 -0
  36. dao_modman/tui/app.tcss +126 -0
  37. dao_modman/tui/screens.py +748 -0
  38. dao_modman-0.1.0.dist-info/METADATA +161 -0
  39. dao_modman-0.1.0.dist-info/RECORD +42 -0
  40. dao_modman-0.1.0.dist-info/WHEEL +4 -0
  41. dao_modman-0.1.0.dist-info/entry_points.txt +2 -0
  42. dao_modman-0.1.0.dist-info/licenses/LICENSE +21 -0
dao_modman/__init__.py ADDED
File without changes
dao_modman/__main__.py ADDED
@@ -0,0 +1,252 @@
1
+ """Command line entry point: the TUI by default, plus `nxm://` link handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from .core import console, deploy, ipc, laa, nexus, paths, report, revisions, terminal
13
+ from .core.config import TOKEN_VAR, Config, load_config, load_token
14
+ from .core.library import Library
15
+ from .core.nexus import NexusError
16
+
17
+ DESKTOP_FILE = "dao-modman-nxm.desktop"
18
+ DESKTOP_ENTRY = """[Desktop Entry]
19
+ Type=Application
20
+ Name=dao-modman (nxm links)
21
+ Exec="{python}" -m dao_modman nxm %u
22
+ Terminal=false
23
+ NoDisplay=true
24
+ MimeType=x-scheme-handler/nxm;
25
+ """
26
+
27
+
28
+ def _report(message: str) -> None:
29
+ """The handler has no terminal of its own, so errors also go to the desktop."""
30
+ print(f"error: {message}", file=sys.stderr)
31
+ if shutil.which("notify-send"):
32
+ subprocess.run(["notify-send", "--app-name=dao-modman", "dao-modman", message], check=False)
33
+
34
+
35
+ def _forward(config: Config, url: str) -> bool:
36
+ """Hand the link to the running instance, if there is one."""
37
+ if config.runtime_dir is None or not ipc.is_running(config.runtime_dir):
38
+ return False
39
+ ipc.send_link(config.runtime_dir, url)
40
+ return True
41
+
42
+
43
+ def cmd_nxm(url: str) -> int:
44
+ """Pass a link to the running instance, or start one in a new terminal window."""
45
+ config = load_config()
46
+ try:
47
+ nexus.parse_nxm(url)
48
+ if not _forward(config, url):
49
+ argv = [sys.executable, "-m", "dao_modman", "--nxm", url]
50
+ subprocess.Popen(
51
+ terminal.command(config.terminal, argv),
52
+ start_new_session=True,
53
+ stdin=subprocess.DEVNULL,
54
+ stdout=subprocess.DEVNULL,
55
+ stderr=subprocess.DEVNULL,
56
+ )
57
+ except (NexusError, ipc.IpcError, terminal.TerminalError, OSError) as exc:
58
+ _report(str(exc))
59
+ return 1
60
+ return 0
61
+
62
+
63
+ def cmd_install_nxm_handler() -> int:
64
+ applications = Path.home() / ".local/share/applications"
65
+ applications.mkdir(parents=True, exist_ok=True)
66
+ desktop = applications / DESKTOP_FILE
67
+ desktop.write_text(DESKTOP_ENTRY.format(python=sys.executable), encoding="utf-8")
68
+ print(f"wrote {desktop}")
69
+ if not shutil.which("xdg-mime"):
70
+ print("xdg-mime not found: register the nxm:// handler manually", file=sys.stderr)
71
+ return 1
72
+ # On KDE, xdg-mime calls qtpaths, which some distros keep outside PATH.
73
+ env = {**os.environ, "PATH": f"{os.environ.get('PATH', '')}:/usr/lib/qt6/bin"}
74
+ command = ["xdg-mime", "default", DESKTOP_FILE, "x-scheme-handler/nxm"]
75
+ subprocess.run(command, check=True, env=env)
76
+ print("registered as handler for nxm:// links")
77
+ # The browser starts the handler outside the project dir, where ./.env is not found.
78
+ config_dir = paths.config_dir()
79
+ if not os.environ.get(TOKEN_VAR) and not load_token(config_dir, cwd=config_dir):
80
+ print(
81
+ f"note: the handler will not find your API token: put {TOKEN_VAR}=... "
82
+ f"into {config_dir / '.env'}"
83
+ )
84
+ return 0
85
+
86
+
87
+ def _library(config: Config) -> Library:
88
+ return Library(config.dirs, config.ignore)
89
+
90
+
91
+ def cmd_laa(action: str, exe: Path | None) -> int:
92
+ install_dir = load_config().game_install_dir
93
+ if not install_dir:
94
+ print(
95
+ "error: game install dir not found: set game_install_dir in config.toml",
96
+ file=sys.stderr,
97
+ )
98
+ return 1
99
+ try:
100
+ if action == "patch":
101
+ if exe is None:
102
+ print(
103
+ "error: pass the LAA-patched executable with --exe PATH; how to get one:\n"
104
+ f" {laa.GUIDE_URL}",
105
+ file=sys.stderr,
106
+ )
107
+ return 1
108
+ backup = laa.patch(install_dir, exe)
109
+ print(f"patched {laa.game_exe(install_dir)}\noriginal saved as {backup}")
110
+ elif action == "restore":
111
+ print(f"restored {laa.restore(install_dir)}")
112
+ else:
113
+ print(f"{laa.game_exe(install_dir)}: {laa.status(install_dir)}")
114
+ except laa.LaaError as exc:
115
+ print(f"error: {exc}", file=sys.stderr)
116
+ return 1
117
+ return 0
118
+
119
+
120
+ def cmd_console(action: str) -> int:
121
+ config = load_config()
122
+ if not config.game_install_dir:
123
+ print(
124
+ "error: game install dir not found: set game_install_dir in config.toml",
125
+ file=sys.stderr,
126
+ )
127
+ return 1
128
+ try:
129
+ if action == "enable":
130
+ console.enable(config.game_install_dir)
131
+ key = console.ensure_key(config.game_user_dir) if config.game_user_dir else None
132
+ print(f"console enabled, open it with: {console.key_label(key)}")
133
+ if not console.fonts_installed(config.game_user_dir, _library(config)):
134
+ print(f"console text is invisible without fonts.erf: {console.FONTS_MOD_URL}")
135
+ elif action == "disable":
136
+ console.disable(config.game_install_dir)
137
+ print("console disabled")
138
+ else:
139
+ state = "enabled" if console.is_enabled(config.game_install_dir) else "disabled"
140
+ print(f"console: {state}")
141
+ except console.ConsoleError as exc:
142
+ print(f"error: {exc}", file=sys.stderr)
143
+ return 1
144
+ return 0
145
+
146
+
147
+ def cmd_conflicts(action: str | None, names: list[str], accept_all: bool) -> int:
148
+ library = _library(load_config())
149
+ plan = deploy.build_plan(library)
150
+ if action is None:
151
+ print(report.format_conflicts(library, plan) or "no conflicts")
152
+ return 1 if plan.with_status(deploy.ERROR) else 0
153
+
154
+ if bool(names) == accept_all:
155
+ print("error: give file names or mod ids to accept, or --all (not both)", file=sys.stderr)
156
+ return 2
157
+ selected, unmatched = report.select_conflicts(plan, names)
158
+ if unmatched:
159
+ print(f"error: no open conflict matches {', '.join(unmatched)}", file=sys.stderr)
160
+ return 1
161
+ for conflict in selected:
162
+ *losers, winner = conflict.mod_ids
163
+ library.accept_overwrite(winner, losers, conflict.name)
164
+ print(f"accepted {conflict.name}: {winner} overwrites {', '.join(losers)}")
165
+ if not selected:
166
+ print("no open conflicts")
167
+ for conflict in plan.with_status(deploy.ERROR):
168
+ print(f"not accepted: {report.file_line(library, conflict)}")
169
+ return 0
170
+
171
+
172
+ def cmd_chargen() -> int:
173
+ config = load_config()
174
+ result, text = report.chargen_report(config.game_install_dir, _library(config))
175
+ print(text)
176
+ return 1 if result and result.dangling else 0
177
+
178
+
179
+ def cmd_revisions() -> int:
180
+ data_dir = load_config().dirs.data
181
+ if not revisions.init(data_dir):
182
+ print("error: could not create a git repository (is git installed?)", file=sys.stderr)
183
+ return 1
184
+ print(f"tracking the library's metadata in {data_dir}")
185
+ print("every change is now committed there; look at it with:")
186
+ print(f" git -C '{data_dir}' log --stat")
187
+ print(f"to stop, delete {data_dir / '.git'}")
188
+ return 0
189
+
190
+
191
+ def main(argv: list[str] | None = None) -> int:
192
+ parser = argparse.ArgumentParser(prog="dao-modman", description=__doc__)
193
+ commands = parser.add_subparsers(dest="command")
194
+ parser.add_argument("--nxm", metavar="URL", help="start the TUI and download this link")
195
+ nxm = commands.add_parser("nxm", help="hand a 'Mod Manager Download' link to the TUI")
196
+ nxm.add_argument("url")
197
+ commands.add_parser("install-nxm-handler", help="register dao-modman for nxm:// links")
198
+ laa_cmd = commands.add_parser("laa", help="Large Address Aware patch for the game exe")
199
+ laa_cmd.add_argument("action", choices=["status", "patch", "restore"])
200
+ laa_cmd.add_argument("--exe", type=Path, help="LAA-patched executable to install (for patch)")
201
+ console_cmd = commands.add_parser("console", help="enable the game's developer console")
202
+ console_cmd.add_argument("action", choices=["status", "enable", "disable"])
203
+ conflicts = commands.add_parser(
204
+ "conflicts", help="report files that several mods ship, or accept them as intended"
205
+ )
206
+ conflicts.add_argument("action", nargs="?", choices=["accept"])
207
+ conflicts.add_argument(
208
+ "names", nargs="*", help="with accept: file names or mod ids of the conflicts to accept"
209
+ )
210
+ conflicts.add_argument("--all", action="store_true", help="with accept: every open conflict")
211
+ revisions_cmd = commands.add_parser(
212
+ "revisions", help="keep a history of the library's metadata in git (debugging aid)"
213
+ )
214
+ revisions_cmd.add_argument("action", choices=["init"])
215
+ commands.add_parser(
216
+ "chargen", help="report on the merged character creator list and its broken entries"
217
+ )
218
+ args = parser.parse_args(argv)
219
+
220
+ if args.command == "nxm":
221
+ return cmd_nxm(args.url)
222
+ if args.command == "chargen":
223
+ return cmd_chargen()
224
+ if args.command == "revisions":
225
+ return cmd_revisions()
226
+ if args.command == "conflicts":
227
+ return cmd_conflicts(args.action, args.names, args.all)
228
+ if args.command == "console":
229
+ return cmd_console(args.action)
230
+ if args.command == "laa":
231
+ return cmd_laa(args.action, args.exe)
232
+ if args.command == "install-nxm-handler":
233
+ return cmd_install_nxm_handler()
234
+
235
+ config = load_config()
236
+ if args.nxm:
237
+ # Lost the race against another instance that is starting: let that one handle it.
238
+ try:
239
+ if _forward(config, args.nxm):
240
+ return 0
241
+ except ipc.IpcError as exc:
242
+ _report(str(exc))
243
+ return 1
244
+
245
+ from .tui.app import ModManApp
246
+
247
+ ModManApp(config, links=[args.nxm] if args.nxm else []).run()
248
+ return 0
249
+
250
+
251
+ if __name__ == "__main__":
252
+ sys.exit(main())
@@ -0,0 +1,28 @@
1
+ # Predefined path operations for mods that only work when installed at a specific place.
2
+ #
3
+ # An entry is identified by the Nexus mod id and file id. Operations have the same form as
4
+ # in a mod's mod.toml; {mod} stands for the mod's own override folder
5
+ # (packages/core/override/<mod-id>), which differs from one library to the next.
6
+ #
7
+ # The same file in ~/.config/dao-modman/ adds entries, and wins over an entry here for the
8
+ # same mod and file.
9
+
10
+ [[preset]]
11
+ mod_id = 1825
12
+ file_id = 9280
13
+ description = "The bundled exe has hardcoded paths. For the mod to work, the exe needs to be placed in override/siramods_items"
14
+
15
+ [[preset.ops]]
16
+ op = "rename"
17
+ from = "{mod}/ADD_ITEM_DELUXE_1_1/siramods_items"
18
+ to = "packages/core/override/siramods_items"
19
+
20
+ [[preset]]
21
+ mod_id = 1110
22
+ file_id = 5571
23
+ description = "The game only reads fonts.erf from packages/core/data, not from the override folder"
24
+
25
+ [[preset.ops]]
26
+ op = "rename"
27
+ from = "{mod}/fonts.erf"
28
+ to = "packages/core/data/fonts.erf"
File without changes
@@ -0,0 +1,120 @@
1
+ """Register / unregister dazip AddIns in the game's `Settings/AddIns.xml`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import xml.etree.ElementTree as ET
7
+ from pathlib import Path
8
+
9
+ from .dazip import AddIn, addin_from_element
10
+ from .metadata import write_atomic
11
+ from .paths import resolve_case
12
+
13
+ ADDINS_REL = "Settings/AddIns.xml"
14
+ SHIPPED_REL = "Addins" # in the game's install dir
15
+
16
+ MANAGED = "managed" # registered by one of our mods
17
+ OFFICIAL = "official" # the game's own DLC
18
+ EXTERNAL = "external" # installed by hand or by another tool
19
+
20
+
21
+ class AddInsError(Exception):
22
+ pass
23
+
24
+
25
+ def list_registered(user_dir: Path) -> list[AddIn]:
26
+ """Everything registered in the game's `Settings/addins.xml`."""
27
+ addins_xml = resolve_case(user_dir, ADDINS_REL)
28
+ if not addins_xml.is_file():
29
+ return []
30
+ try:
31
+ root = _load(addins_xml)
32
+ except (ET.ParseError, OSError, ValueError) as exc:
33
+ raise AddInsError(f"cannot read {addins_xml}: {exc}") from exc
34
+ return [addin for item in root.findall("AddInItem") if (addin := addin_from_element(item))]
35
+
36
+
37
+ def shipped_uids(install_dir: Path | None) -> set[str]:
38
+ """Case-folded UIDs of the AddIns that ship in the game's install dir."""
39
+ if install_dir is None:
40
+ return set()
41
+ shipped = resolve_case(install_dir, SHIPPED_REL)
42
+ if not shipped.is_dir():
43
+ return set()
44
+ return {entry.name.casefold() for entry in shipped.iterdir() if entry.is_dir()}
45
+
46
+
47
+ def classify(addin: AddIn, managed: set[str], shipped: set[str]) -> str:
48
+ """Tell our mods, official DLC and foreign AddIns apart.
49
+
50
+ Being shipped in the install dir is the reliable sign of official content. The
51
+ `BioWare="1"` attribute is only self-declared, but it is all there is for DLC that was
52
+ downloaded into the user dir (non-Ultimate editions). Our own mods are ruled out first,
53
+ so a mod claiming to be BioWare content is not mistaken for DLC.
54
+ """
55
+ if addin.uid.casefold() in {uid.casefold() for uid in managed}:
56
+ return MANAGED
57
+ if addin.uid.casefold() in shipped or addin.bioware:
58
+ return OFFICIAL
59
+ return EXTERNAL
60
+
61
+
62
+ def _load(path: Path) -> ET.Element:
63
+ if path.is_file():
64
+ root = ET.parse(path).getroot()
65
+ if root.tag != "AddInsList":
66
+ raise ValueError(f"{path}: unexpected root element <{root.tag}>")
67
+ return root
68
+ return ET.Element("AddInsList")
69
+
70
+
71
+ def _save(path: Path, root: ET.Element) -> None:
72
+ path.parent.mkdir(parents=True, exist_ok=True)
73
+ ET.indent(root)
74
+ write_atomic(path, ET.tostring(root, encoding="utf-8", xml_declaration=True))
75
+
76
+
77
+ def absent(addins_xml: Path, addins: list[AddIn]) -> list[str]:
78
+ """UIDs of `addins` that `register` would add, i.e. that are not registered yet."""
79
+ present = {(item.get("UID") or "").casefold() for item in _load(addins_xml).iter("AddInItem")}
80
+ missing: list[str] = []
81
+ for addin in addins:
82
+ if addin.uid.casefold() not in present:
83
+ present.add(addin.uid.casefold())
84
+ missing.append(addin.uid)
85
+ return missing
86
+
87
+
88
+ def register(addins_xml: Path, addins: list[AddIn]) -> list[str]:
89
+ """Add missing AddIns; returns the UIDs that were actually added."""
90
+ if not addins:
91
+ return []
92
+ root = _load(addins_xml)
93
+ # The game treats UIDs case-insensitively: "MyAddIn" and "myaddin" are the same AddIn.
94
+ present = {(item.get("UID") or "").casefold() for item in root.iter("AddInItem")}
95
+ added = []
96
+ for addin in addins:
97
+ if addin.uid.casefold() in present:
98
+ continue
99
+ root.append(copy.deepcopy(addin.element))
100
+ present.add(addin.uid.casefold())
101
+ added.append(addin.uid)
102
+ if added:
103
+ _save(addins_xml, root)
104
+ return added
105
+
106
+
107
+ def unregister(addins_xml: Path, uids: list[str]) -> list[str]:
108
+ """Remove AddIns by UID; returns the UIDs that were removed."""
109
+ if not uids or not addins_xml.is_file():
110
+ return []
111
+ root = _load(addins_xml)
112
+ removed = []
113
+ wanted = {uid.casefold() for uid in uids}
114
+ for item in list(root.findall("AddInItem")):
115
+ if (item.get("UID") or "").casefold() in wanted:
116
+ root.remove(item)
117
+ removed.append(item.get("UID"))
118
+ if removed:
119
+ _save(addins_xml, root)
120
+ return removed
@@ -0,0 +1,129 @@
1
+ """Hashing and safe extraction of mod archives (zip/dazip/override, 7z, rar)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import zipfile
10
+ from pathlib import Path
11
+
12
+ import py7zr
13
+
14
+ from .paths import normalize_rel
15
+
16
+ _CHUNK = 1024 * 1024
17
+ _SEVENZIP_MAGIC = b"7z\xbc\xaf\x27\x1c"
18
+ _RAR_MAGIC = b"Rar!\x1a\x07"
19
+
20
+
21
+ class ArchiveError(Exception):
22
+ pass
23
+
24
+
25
+ def file_hashes(path: Path) -> tuple[str, str]:
26
+ """Return (sha256, md5) of a file."""
27
+ sha256, md5 = hashlib.sha256(), hashlib.md5(usedforsecurity=False)
28
+ with path.open("rb") as fh:
29
+ while chunk := fh.read(_CHUNK):
30
+ sha256.update(chunk)
31
+ md5.update(chunk)
32
+ return sha256.hexdigest(), md5.hexdigest()
33
+
34
+
35
+ def sha256_file(path: Path) -> str:
36
+ return file_hashes(path)[0]
37
+
38
+
39
+ def _safe_name(name: str) -> str:
40
+ try:
41
+ if name.startswith(("/", "\\")):
42
+ raise ValueError("absolute path")
43
+ return normalize_rel(name)
44
+ except ValueError as exc:
45
+ raise ArchiveError(f"unsafe path in archive: {name!r}") from exc
46
+
47
+
48
+ def _extract_zip(archive: Path, dest: Path) -> None:
49
+ with zipfile.ZipFile(archive) as zf:
50
+ for info in zf.infolist():
51
+ if info.is_dir():
52
+ continue
53
+ target = dest / _safe_name(info.filename)
54
+ target.parent.mkdir(parents=True, exist_ok=True)
55
+ with zf.open(info) as src, target.open("wb") as out:
56
+ shutil.copyfileobj(src, out, _CHUNK)
57
+
58
+
59
+ def _extract_7z(archive: Path, dest: Path) -> None:
60
+ with py7zr.SevenZipFile(archive) as sz:
61
+ for name in sz.getnames():
62
+ _safe_name(name)
63
+ sz.extractall(dest)
64
+
65
+
66
+ def _extract_rar(archive: Path, dest: Path) -> None:
67
+ """Extract with whichever tool manages: no pure-Python rar reader exists.
68
+
69
+ unrar and 7z come first because bsdtar cannot read solid archives, which are common.
70
+ All three refuse to write outside the target directory by default.
71
+ """
72
+ commands = {
73
+ "unrar": ["unrar", "x", "-y", "-o+", str(archive), f"{dest}{os.sep}"],
74
+ "7z": ["7z", "x", "-y", f"-o{dest}", str(archive)],
75
+ "bsdtar": ["bsdtar", "-xf", str(archive), "-C", str(dest)],
76
+ }
77
+ failures = []
78
+ for tool, command in commands.items():
79
+ if not shutil.which(tool):
80
+ continue
81
+ result = subprocess.run(command, capture_output=True, text=True, stdin=subprocess.DEVNULL)
82
+ if result.returncode == 0:
83
+ return
84
+ detail = (result.stderr or result.stdout).strip().splitlines()
85
+ failures.append(f"{tool}: {detail[-1] if detail else 'failed'}")
86
+ shutil.rmtree(dest) # whatever the failed tool left behind must not be imported
87
+ dest.mkdir()
88
+ if not failures:
89
+ raise ArchiveError("rar archives need one of unrar, 7z or bsdtar on PATH")
90
+ raise ArchiveError(f"cannot extract {archive.name} ({'; '.join(failures)})")
91
+
92
+
93
+ def _drop_links(dest: Path) -> None:
94
+ """Archives may carry symlinks; they must never be followed when deploying."""
95
+ for root, dirnames, filenames in os.walk(dest):
96
+ for name in dirnames + filenames:
97
+ path = Path(root, name)
98
+ if path.is_symlink():
99
+ path.unlink()
100
+
101
+
102
+ def extract(archive: Path, dest: Path) -> None:
103
+ """Extract `archive` into the (new or empty) directory `dest`."""
104
+ dest.mkdir(parents=True, exist_ok=True)
105
+ with archive.open("rb") as fh:
106
+ magic = fh.read(8)
107
+ try:
108
+ if magic.startswith(_SEVENZIP_MAGIC):
109
+ _extract_7z(archive, dest)
110
+ elif magic.startswith(_RAR_MAGIC):
111
+ _extract_rar(archive, dest)
112
+ elif zipfile.is_zipfile(archive):
113
+ _extract_zip(archive, dest)
114
+ else:
115
+ raise ArchiveError(f"unsupported archive format: {archive.name}")
116
+ except (zipfile.BadZipFile, py7zr.exceptions.ArchiveError, OSError) as exc:
117
+ raise ArchiveError(f"cannot extract {archive.name}: {exc}") from exc
118
+ _drop_links(dest)
119
+
120
+
121
+ def list_files(root: Path) -> list[str]:
122
+ """Relative posix paths of all regular files below `root`, sorted."""
123
+ files = []
124
+ for dirpath, _dirnames, filenames in os.walk(root):
125
+ for name in filenames:
126
+ path = Path(dirpath, name)
127
+ if path.is_file() and not path.is_symlink():
128
+ files.append(path.relative_to(root).as_posix())
129
+ return sorted(files)